Merge pull request #29 from ansible/devel

Rebase
This commit is contained in:
Sean Sullivan
2021-01-22 12:01:25 -06:00
committed by GitHub
123 changed files with 4744 additions and 1452 deletions

View File

@@ -2,6 +2,30 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`. This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
# 17.0.0 (January 22, 2021)
- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943
**Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook
- Added support for region name for OpenStack inventory: https://github.com/ansible/awx/issues/5080
- Added the ability to chain undefined attributes in custom notification templates: https://github.com/ansible/awx/issues/8677
- Dramatically simplified the `image_build` role: https://github.com/ansible/awx/pull/8980
- Fixed a bug which can cause schema migrations to fail at install time: https://github.com/ansible/awx/issues/9077
- Fixed a bug which caused the `is_superuser` user property to be out of date in certain circumstances: https://github.com/ansible/awx/pull/8833
- Fixed a bug which sometimes results in race conditions on setting access: https://github.com/ansible/awx/pull/8580
- Fixed a bug which sometimes causes an unexpected delay in stdout for some playbooks: https://github.com/ansible/awx/issues/9085
- (UI) Added support for credential password prompting on job launch: https://github.com/ansible/awx/pull/9028
- (UI) Added the ability to configure LDAP settings in the UI: https://github.com/ansible/awx/issues/8291
- (UI) Added a sync button to the Project detail view: https://github.com/ansible/awx/issues/8847
- (UI) Added a form for configuring Google Outh 2.0 settings: https://github.com/ansible/awx/pull/8762
- (UI) Added searchable keys and related keys to the Credentials list: https://github.com/ansible/awx/issues/8603
- (UI) Added support for advanced search and copying to Notification Templates: https://github.com/ansible/awx/issues/7879
- (UI) Added support for prompting on workflow nodes: https://github.com/ansible/awx/issues/5913
- (UI) Added support for session timeouts: https://github.com/ansible/awx/pull/8250
- (UI) Fixed a bug that broke websocket streaming for the insecure ws:// protocol: https://github.com/ansible/awx/pull/8877
- (UI) Fixed a bug in the user interface when a translation for the browser's preferred locale isn't available: https://github.com/ansible/awx/issues/8884
- (UI) Fixed bug where navigating from one survey question form directly to another wasn't reloading the form: https://github.com/ansible/awx/issues/7522
- (UI) Fixed a bug which can cause an uncaught error while launching a Job Template: https://github.com/ansible/awx/issues/8936
- Updated autobahn to address CVE-2020-35678
## 16.0.0 (December 10, 2020) ## 16.0.0 (December 10, 2020)
- AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo - AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo
- Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2 - Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2

View File

@@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor
*docker_compose_dir* *docker_compose_dir*
> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`). > When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`).
*custom_venv_dir* *custom_venv_dir*

View File

@@ -1 +1 @@
16.0.0 17.0.0

View File

@@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker):
MAX_RETRIES = 2 MAX_RETRIES = 2
last_stats = time.time() last_stats = time.time()
last_flush = time.time()
total = 0 total = 0
last_event = '' last_event = ''
prof = None prof = None
@@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker):
def read(self, queue): def read(self, queue):
try: try:
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS) res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1)
if res is None: if res is None:
return {'event': 'FLUSH'} return {'event': 'FLUSH'}
self.total += 1 self.total += 1
@@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker):
now = tz_now() now = tz_now()
if ( if (
force or force or
(time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or
any([len(events) >= 1000 for events in self.buff.values()]) any([len(events) >= 1000 for events in self.buff.values()])
): ):
for cls, events in self.buff.items(): for cls, events in self.buff.items():
@@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker):
for e in events: for e in events:
emit_event_detail(e) emit_event_detail(e)
self.buff = {} self.buff = {}
self.last_flush = time.time()
def perform_work(self, body): def perform_work(self, body):
try: try:

View File

@@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
# events into the database # events into the database
JOB_EVENT_WORKERS = 4 JOB_EVENT_WORKERS = 4
# The number of seconds (must be an integer) to buffer callback receiver bulk # The number of seconds to buffer callback receiver bulk
# writes in memory before flushing via JobEvent.objects.bulk_create() # writes in memory before flushing via JobEvent.objects.bulk_create()
JOB_EVENT_BUFFER_SECONDS = 1 JOB_EVENT_BUFFER_SECONDS = .1
# The interval at which callback receiver statistics should be # The interval at which callback receiver statistics should be
# recorded # recorded

View File

@@ -1,3 +1,4 @@
import ActivityStream from './models/ActivityStream';
import AdHocCommands from './models/AdHocCommands'; import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications'; import Applications from './models/Applications';
import Auth from './models/Auth'; import Auth from './models/Auth';
@@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates'; import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs'; import WorkflowJobs from './models/WorkflowJobs';
const ActivityStreamAPI = new ActivityStream();
const AdHocCommandsAPI = new AdHocCommands(); const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications(); const ApplicationsAPI = new Applications();
const AuthAPI = new Auth(); const AuthAPI = new Auth();
@@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs(); const WorkflowJobsAPI = new WorkflowJobs();
export { export {
ActivityStreamAPI,
AdHocCommandsAPI, AdHocCommandsAPI,
ApplicationsAPI, ApplicationsAPI,
AuthAPI, AuthAPI,

View File

@@ -0,0 +1,10 @@
import Base from '../Base';
class ActivityStream extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/activity_stream/';
}
}
export default ActivityStream;

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams);
const readTeamsOptions = async () => TeamsAPI.readOptions(); const readTeamsOptions = async () => TeamsAPI.readOptions();
class AddResourceRole extends React.Component { function AddResourceRole({ onSave, onClose, roles, i18n, resource }) {
constructor(props) { const [selectedResource, setSelectedResource] = useState(null);
super(props); const [selectedResourceRows, setSelectedResourceRows] = useState([]);
const [selectedRoleRows, setSelectedRoleRows] = useState([]);
this.state = { const [currentStepId, setCurrentStepId] = useState(1);
selectedResource: null, const [maxEnabledStep, setMaxEnabledStep] = useState(1);
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
};
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(
this
);
this.handleResourceSelect = this.handleResourceSelect.bind(this);
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
this.handleWizardNext = this.handleWizardNext.bind(this);
this.handleWizardSave = this.handleWizardSave.bind(this);
this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this);
}
handleResourceCheckboxClick(user) {
const { selectedResourceRows, currentStepId } = this.state;
const handleResourceCheckboxClick = user => {
const selectedIndex = selectedResourceRows.findIndex( const selectedIndex = selectedResourceRows.findIndex(
selectedRow => selectedRow.id === user.id selectedRow => selectedRow.id === user.id
); );
if (selectedIndex > -1) { if (selectedIndex > -1) {
selectedResourceRows.splice(selectedIndex, 1); selectedResourceRows.splice(selectedIndex, 1);
const stateToUpdate = { selectedResourceRows };
if (selectedResourceRows.length === 0) { if (selectedResourceRows.length === 0) {
stateToUpdate.maxEnabledStep = currentStepId; setMaxEnabledStep(currentStepId);
} }
this.setState(stateToUpdate); setSelectedRoleRows(selectedResourceRows);
} else { } else {
this.setState(prevState => ({ setSelectedResourceRows([...selectedResourceRows, user]);
selectedResourceRows: [...prevState.selectedResourceRows, user],
}));
} }
} };
handleRoleCheckboxClick(role) {
const { selectedRoleRows } = this.state;
const handleRoleCheckboxClick = role => {
const selectedIndex = selectedRoleRows.findIndex( const selectedIndex = selectedRoleRows.findIndex(
selectedRow => selectedRow.id === role.id selectedRow => selectedRow.id === role.id
); );
if (selectedIndex > -1) { if (selectedIndex > -1) {
selectedRoleRows.splice(selectedIndex, 1); selectedRoleRows.splice(selectedIndex, 1);
this.setState({ selectedRoleRows }); setSelectedRoleRows(selectedRoleRows);
} else { } else {
this.setState(prevState => ({ setSelectedRoleRows([...selectedRoleRows, role]);
selectedRoleRows: [...prevState.selectedRoleRows, role],
}));
} }
} };
handleResourceSelect(resourceType) { const handleResourceSelect = resourceType => {
this.setState({ setSelectedResource(resourceType);
selectedResource: resourceType, setSelectedResourceRows([]);
selectedResourceRows: [], setSelectedRoleRows([]);
selectedRoleRows: [], };
});
}
handleWizardNext(step) { const handleWizardNext = step => {
this.setState({ setCurrentStepId(step.id);
currentStepId: step.id, setMaxEnabledStep(step.id);
maxEnabledStep: step.id, };
});
}
handleWizardGoToStep(step) { const handleWizardGoToStep = step => {
this.setState({ setCurrentStepId(step.id);
currentStepId: step.id, };
});
}
async handleWizardSave() {
const { onSave } = this.props;
const {
selectedResourceRows,
selectedRoleRows,
selectedResource,
} = this.state;
const handleWizardSave = async () => {
try { try {
const roleRequests = []; const roleRequests = [];
@@ -134,201 +96,186 @@ class AddResourceRole extends React.Component {
} catch (err) { } catch (err) {
// TODO: handle this error // TODO: handle this error
} }
};
// Object roles can be user only, so we remove them when
// showing role choices for team access
const selectableRoles = { ...roles };
if (selectedResource === 'teams') {
Object.keys(roles).forEach(key => {
if (selectableRoles[key].user_only) {
delete selectableRoles[key];
}
});
} }
render() { const userSearchColumns = [
const { {
selectedResource, name: i18n._(t`Username`),
selectedResourceRows, key: 'username__icontains',
selectedRoleRows, isDefault: true,
currentStepId, },
maxEnabledStep, {
} = this.state; name: i18n._(t`First Name`),
const { onClose, roles, i18n, resource } = this.props; key: 'first_name__icontains',
},
{
name: i18n._(t`Last Name`),
key: 'last_name__icontains',
},
];
const userSortColumns = [
{
name: i18n._(t`Username`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamSearchColumns = [
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
];
// Object roles can be user only, so we remove them when const teamSortColumns = [
// showing role choices for team access {
const selectableRoles = { ...roles }; name: i18n._(t`Name`),
if (selectedResource === 'teams') { key: 'name',
Object.keys(roles).forEach(key => { },
if (selectableRoles[key].user_only) { ];
delete selectableRoles[key];
}
});
}
const userSearchColumns = [ let wizardTitle = '';
{
name: i18n._(t`Username`),
key: 'username__icontains',
isDefault: true,
},
{
name: i18n._(t`First Name`),
key: 'first_name__icontains',
},
{
name: i18n._(t`Last Name`),
key: 'last_name__icontains',
},
];
const userSortColumns = [ switch (selectedResource) {
{ case 'users':
name: i18n._(t`Username`), wizardTitle = i18n._(t`Add User Roles`);
key: 'username', break;
}, case 'teams':
{ wizardTitle = i18n._(t`Add Team Roles`);
name: i18n._(t`First Name`), break;
key: 'first_name', default:
}, wizardTitle = i18n._(t`Add Roles`);
{ }
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
const teamSearchColumns = [ const steps = [
{ {
name: i18n._(t`Name`), id: 1,
key: 'name', name: i18n._(t`Select a Resource Type`),
isDefault: true, component: (
}, <div style={{ display: 'flex', flexWrap: 'wrap' }}>
{ <div style={{ width: '100%', marginBottom: '10px' }}>
name: i18n._(t`Created By (Username)`), {i18n._(
key: 'created_by__username', t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username',
},
];
const teamSortColumns = [
{
name: i18n._(t`Name`),
key: 'name',
},
];
let wizardTitle = '';
switch (selectedResource) {
case 'users':
wizardTitle = i18n._(t`Add User Roles`);
break;
case 'teams':
wizardTitle = i18n._(t`Add Team Roles`);
break;
default:
wizardTitle = i18n._(t`Add Roles`);
}
const steps = [
{
id: 1,
name: i18n._(t`Select a Resource Type`),
component: (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
<div style={{ width: '100%', marginBottom: '10px' }}>
{i18n._(
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
)}
</div>
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
dataCy="add-role-users"
ariaLabel={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')}
/>
{resource?.type === 'credential' &&
!resource?.organization ? null : (
<SelectableCard
isSelected={selectedResource === 'teams'}
label={i18n._(t`Teams`)}
dataCy="add-role-teams"
ariaLabel={i18n._(t`Teams`)}
onClick={() => this.handleResourceSelect('teams')}
/>
)} )}
</div> </div>
), <SelectableCard
enableNext: selectedResource !== null, isSelected={selectedResource === 'users'}
}, label={i18n._(t`Users`)}
{ ariaLabel={i18n._(t`Users`)}
id: 2, dataCy="add-role-users"
name: i18n._(t`Select Items from List`), onClick={() => handleResourceSelect('users')}
component: (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
searchColumns={userSearchColumns}
sortColumns={userSortColumns}
displayKey="username"
onRowClick={this.handleResourceCheckboxClick}
fetchItems={readUsers}
fetchOptions={readUsersOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={this.handleResourceCheckboxClick}
fetchItems={readTeams}
fetchOptions={readTeamsOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>
)}
</Fragment>
),
enableNext: selectedResourceRows.length > 0,
canJumpTo: maxEnabledStep >= 2,
},
{
id: 3,
name: i18n._(t`Select Roles to Apply`),
component: (
<SelectRoleStep
onRolesClick={this.handleRoleCheckboxClick}
roles={selectableRoles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/> />
), {resource?.type === 'credential' && !resource?.organization ? null : (
nextButtonText: i18n._(t`Save`), <SelectableCard
enableNext: selectedRoleRows.length > 0, isSelected={selectedResource === 'teams'}
canJumpTo: maxEnabledStep >= 3, label={i18n._(t`Teams`)}
}, ariaLabel={i18n._(t`Teams`)}
]; dataCy="add-role-teams"
onClick={() => handleResourceSelect('teams')}
/>
)}
</div>
),
enableNext: selectedResource !== null,
},
{
id: 2,
name: i18n._(t`Select Items from List`),
component: (
<Fragment>
{selectedResource === 'users' && (
<SelectResourceStep
searchColumns={userSearchColumns}
sortColumns={userSortColumns}
displayKey="username"
onRowClick={handleResourceCheckboxClick}
fetchItems={readUsers}
fetchOptions={readUsersOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
sortedColumnKey="username"
/>
)}
{selectedResource === 'teams' && (
<SelectResourceStep
searchColumns={teamSearchColumns}
sortColumns={teamSortColumns}
onRowClick={handleResourceCheckboxClick}
fetchItems={readTeams}
fetchOptions={readTeamsOptions}
selectedLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
/>
)}
</Fragment>
),
enableNext: selectedResourceRows.length > 0,
canJumpTo: maxEnabledStep >= 2,
},
{
id: 3,
name: i18n._(t`Select Roles to Apply`),
component: (
<SelectRoleStep
onRolesClick={handleRoleCheckboxClick}
roles={selectableRoles}
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
selectedListLabel={i18n._(t`Selected`)}
selectedResourceRows={selectedResourceRows}
selectedRoleRows={selectedRoleRows}
/>
),
nextButtonText: i18n._(t`Save`),
enableNext: selectedRoleRows.length > 0,
canJumpTo: maxEnabledStep >= 3,
},
];
const currentStep = steps.find(step => step.id === currentStepId); const currentStep = steps.find(step => step.id === currentStepId);
// TODO: somehow internationalize steps and currentStep.nextButtonText // TODO: somehow internationalize steps and currentStep.nextButtonText
return ( return (
<Wizard <Wizard
style={{ overflow: 'scroll' }} style={{ overflow: 'scroll' }}
isOpen isOpen
onNext={this.handleWizardNext} onNext={handleWizardNext}
onClose={onClose} onClose={onClose}
onSave={this.handleWizardSave} onSave={handleWizardSave}
onGoToStep={this.handleWizardGoToStep} onGoToStep={step => handleWizardGoToStep(step)}
steps={steps} steps={steps}
title={wizardTitle} title={wizardTitle}
nextButtonText={currentStep.nextButtonText || undefined} nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={i18n._(t`Back`)} backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)} cancelButtonText={i18n._(t`Cancel`)}
/> />
); );
}
} }
AddResourceRole.propTypes = { AddResourceRole.propTypes = {

View File

@@ -1,22 +1,46 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import AddResourceRole, { _AddResourceRole } from './AddResourceRole'; import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
import { TeamsAPI, UsersAPI } from '../../api'; import { TeamsAPI, UsersAPI } from '../../api';
jest.mock('../../api'); jest.mock('../../api/models/Teams');
jest.mock('../../api/models/Users');
// TODO: Once error handling is functional in
// this component write tests for it
describe('<_AddResourceRole />', () => { describe('<_AddResourceRole />', () => {
UsersAPI.read.mockResolvedValue({ UsersAPI.read.mockResolvedValue({
data: { data: {
count: 2, count: 2,
results: [ results: [
{ id: 1, username: 'foo' }, { id: 1, username: 'foo', url: '' },
{ id: 2, username: 'bar' }, { id: 2, username: 'bar', url: '' },
], ],
}, },
}); });
UsersAPI.readOptions.mockResolvedValue({
data: { related: {}, actions: { GET: {} } },
});
TeamsAPI.read.mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, name: 'Team foo', url: '' },
{ id: 2, name: 'Team bar', url: '' },
],
},
});
TeamsAPI.readOptions.mockResolvedValue({
data: { related: {}, actions: { GET: {} } },
});
const roles = { const roles = {
admin_role: { admin_role: {
description: 'Can manage all aspects of the organization', description: 'Can manage all aspects of the organization',
@@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => {
/> />
); );
}); });
test('handleRoleCheckboxClick properly updates state', () => { test('should save properly', async () => {
const wrapper = shallow( let wrapper;
<_AddResourceRole act(() => {
onClose={() => {}} wrapper = mountWithContexts(
onSave={() => {}} <AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
roles={roles} { context: { network: { handleHttpError: () => {} } } }
i18n={{ _: val => val.toString() }} );
/>
);
wrapper.setState({
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
],
}); });
wrapper.instance().handleRoleCheckboxClick({ wrapper.update();
description: 'Can manage all aspects of the organization',
name: 'Admin', // Step 1
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([]);
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
});
expect(wrapper.state('selectedRoleRows')).toEqual([
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
]);
});
test('handleResourceCheckboxClick properly updates state', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
});
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([]);
wrapper.instance().handleResourceCheckboxClick({
id: 1,
username: 'foobar',
});
expect(wrapper.state('selectedResourceRows')).toEqual([
{
id: 1,
username: 'foobar',
},
]);
});
test('clicking user/team cards updates state', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard'); const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2); expect(selectableCardWrapper.length).toBe(2);
selectableCardWrapper.first().simulate('click'); act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
expect(spy).toHaveBeenCalledWith('users'); wrapper.update();
expect(wrapper.state('selectedResource')).toBe('users'); await act(async () =>
selectableCardWrapper.at(1).simulate('click'); wrapper.find('Button[type="submit"]').prop('onClick')()
expect(spy).toHaveBeenCalledWith('teams');
expect(wrapper.state('selectedResource')).toBe('teams');
});
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
); );
wrapper.setState({ wrapper.update();
selectedResource: 'teams',
selectedResourceRows: [ // Step 2
{ await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
id: 1, act(() =>
username: 'foobar', wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
}, );
], wrapper.update();
selectedRoleRows: [ expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
{ true
description: 'Can manage all aspects of the organization', );
id: 1, act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
name: 'Admin', wrapper.update();
},
], // Step 3
}); act(() =>
wrapper.instance().handleResourceSelect('users'); wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
expect(wrapper.state()).toEqual({ );
selectedResource: 'users', wrapper.update();
selectedResourceRows: [], expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
selectedRoleRows: [], true
currentStepId: 1, );
maxEnabledStep: 1,
}); // Save
wrapper.instance().handleResourceSelect('teams'); await act(async () =>
expect(wrapper.state()).toEqual({ wrapper.find('Button[type="submit"]').prop('onClick')()
selectedResource: 'teams', );
selectedResourceRows: [], expect(UsersAPI.associateRole).toBeCalledWith(1, 1);
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
});
}); });
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
const handleSave = jest.fn(); test('should successfuly click user/team cards', async () => {
const wrapper = mountWithContexts( let wrapper;
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />, act(() => {
{ context: { network: { handleHttpError: () => {} } } } wrapper = mountWithContexts(
).find('AddResourceRole'); <AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
wrapper.setState({ { context: { network: { handleHttpError: () => {} } } }
selectedResource: 'users', );
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
{
description: 'May run any executable resources in the organization',
id: 2,
name: 'Execute',
},
],
}); });
await wrapper.instance().handleWizardSave(); wrapper.update();
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled(); const selectableCardWrapper = wrapper.find('SelectableCard');
wrapper.setState({ expect(selectableCardWrapper.length).toBe(2);
selectedResource: 'teams', act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
selectedResourceRows: [ wrapper.update();
{
id: 1, await waitForElement(
name: 'foobar', wrapper,
}, 'SelectableCard[label="Users"]',
], el => el.prop('isSelected') === true
selectedRoleRows: [ );
{ act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
description: 'Can manage all aspects of the organization', wrapper.update();
id: 1,
name: 'Admin', await waitForElement(
}, wrapper,
{ 'SelectableCard[label="Teams"]',
description: 'May run any executable resources in the organization', el => el.prop('isSelected') === true
id: 2, );
name: 'Execute', });
},
], test('should reset values with resource type changes', async () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
);
}); });
await wrapper.instance().handleWizardSave(); wrapper.update();
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled(); // Step 1
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// Step 2
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
act(() =>
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
);
wrapper.update();
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
true
);
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
wrapper.update();
// Step 3
act(() =>
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
);
wrapper.update();
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
true
);
// Go back to step 1
act(() => {
wrapper
.find('WizardNavItem[content="Select a Resource Type"]')
.find('button')
.prop('onClick')({ id: 1 });
});
wrapper.update();
expect(
wrapper
.find('WizardNavItem[content="Select a Resource Type"]')
.prop('isCurrent')
).toBe(true);
// Go back to step 1 and this time select teams. Doing so should clear following steps
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.update();
// Make sure no teams have been selected
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
wrapper
.find('DataListCheck')
.map(item => expect(item.prop('checked')).toBe(false));
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
wrapper.update();
// Make sure that no roles have been selected
wrapper
.find('Checkbox')
.map(card => expect(card.prop('isChecked')).toBe(false));
// Make sure the save button is disabled
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
}); });
test('should not display team as a choice in case credential does not have organization', () => { test('should not display team as a choice in case credential does not have organization', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<AddResourceRole <AddResourceRole
onClose={() => {}} onClose={() => {}}
@@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => {
resource={{ type: 'credential', organization: null }} resource={{ type: 'credential', organization: null }}
/>, />,
{ context: { network: { handleHttpError: () => {} } } } { context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole'); );
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(1); expect(wrapper.find('SelectableCard').length).toBe(1);
selectableCardWrapper.first().simulate('click'); wrapper.find('SelectableCard[label="Users"]').simulate('click');
expect(spy).toHaveBeenCalledWith('users'); wrapper.update();
expect(wrapper.state('selectedResource')).toBe('users'); expect(
wrapper.find('SelectableCard[label="Users"]').prop('isSelected')
).toBe(true);
}); });
}); });

View File

@@ -7,59 +7,55 @@ import { t } from '@lingui/macro';
import CheckboxCard from './CheckboxCard'; import CheckboxCard from './CheckboxCard';
import SelectedList from '../SelectedList'; import SelectedList from '../SelectedList';
class RolesStep extends React.Component { function RolesStep({
render() { onRolesClick,
const { roles,
onRolesClick, selectedListKey,
roles, selectedListLabel,
selectedListKey, selectedResourceRows,
selectedListLabel, selectedRoleRows,
selectedResourceRows, i18n,
selectedRoleRows, }) {
i18n, return (
} = this.props; <Fragment>
<div>
return ( {i18n._(
<Fragment> t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
<div> )}
{i18n._( </div>
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.` <div>
)} {selectedResourceRows.length > 0 && (
</div> <SelectedList
<div> displayKey={selectedListKey}
{selectedResourceRows.length > 0 && ( isReadOnly
<SelectedList label={selectedListLabel || i18n._(t`Selected`)}
displayKey={selectedListKey} selected={selectedResourceRows}
isReadOnly />
label={selectedListLabel || i18n._(t`Selected`)} )}
selected={selectedResourceRows} </div>
/> <div
)} style={{
</div> display: 'grid',
<div gridTemplateColumns: '1fr 1fr',
style={{ gap: '20px 20px',
display: 'grid', marginTop: '20px',
gridTemplateColumns: '1fr 1fr', }}
gap: '20px 20px', >
marginTop: '20px', {Object.keys(roles).map(role => (
}} <CheckboxCard
> description={roles[role].description}
{Object.keys(roles).map(role => ( itemId={roles[role].id}
<CheckboxCard isSelected={selectedRoleRows.some(
description={roles[role].description} item => item.id === roles[role].id
itemId={roles[role].id} )}
isSelected={selectedRoleRows.some( key={roles[role].id}
item => item.id === roles[role].id name={roles[role].name}
)} onSelect={() => onRolesClick(roles[role])}
key={roles[role].id} />
name={roles[role].name} ))}
onSelect={() => onRolesClick(roles[role])} </div>
/> </Fragment>
))} );
</div>
</Fragment>
);
}
} }
RolesStep.propTypes = { RolesStep.propTypes = {

View File

@@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { FormSelect, FormSelectOption } from '@patternfly/react-core'; import { FormSelect, FormSelectOption } from '@patternfly/react-core';
class AnsibleSelect extends React.Component { function AnsibleSelect({
constructor(props) { id,
super(props); data,
this.onSelectChange = this.onSelectChange.bind(this); i18n,
} isValid,
onBlur,
onSelectChange(val, event) { value,
const { onChange, name } = this.props; className,
isDisabled,
onChange,
name,
}) {
const onSelectChange = (val, event) => {
event.target.name = name; event.target.name = name;
onChange(event, val); onChange(event, val);
} };
render() { return (
const { <FormSelect
id, id={id}
data, value={value}
i18n, onChange={onSelectChange}
isValid, onBlur={onBlur}
onBlur, aria-label={i18n._(t`Select Input`)}
value, validated={isValid ? 'default' : 'error'}
className, className={className}
isDisabled, isDisabled={isDisabled}
} = this.props; >
{data.map(option => (
return ( <FormSelectOption
<FormSelect key={option.key}
id={id} value={option.value}
value={value} label={option.label}
onChange={this.onSelectChange} isDisabled={option.isDisabled}
onBlur={onBlur} />
aria-label={i18n._(t`Select Input`)} ))}
validated={isValid ? 'default' : 'error'} </FormSelect>
className={className} );
isDisabled={isDisabled}
>
{data.map(option => (
<FormSelectOption
key={option.key}
value={option.value}
label={option.label}
isDisabled={option.isDisabled}
/>
))}
</FormSelect>
);
}
} }
const Option = shape({ const Option = shape({

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect'; import AnsibleSelect from './AnsibleSelect';
const mockData = [ const mockData = [
{ {
@@ -16,6 +16,7 @@ const mockData = [
]; ];
describe('<AnsibleSelect />', () => { describe('<AnsibleSelect />', () => {
const onChange = jest.fn();
test('initially renders succesfully', async () => { test('initially renders succesfully', async () => {
mountWithContexts( mountWithContexts(
<AnsibleSelect <AnsibleSelect
@@ -29,19 +30,18 @@ describe('<AnsibleSelect />', () => {
}); });
test('calls "onSelectChange" on dropdown select change', () => { test('calls "onSelectChange" on dropdown select change', () => {
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<AnsibleSelect <AnsibleSelect
id="bar" id="bar"
value="foo" value="foo"
name="bar" name="bar"
onChange={() => {}} onChange={onChange}
data={mockData} data={mockData}
/> />
); );
expect(spy).not.toHaveBeenCalled(); expect(onChange).not.toHaveBeenCalled();
wrapper.find('select').simulate('change'); wrapper.find('select').simulate('change');
expect(spy).toHaveBeenCalled(); expect(onChange).toHaveBeenCalled();
}); });
test('Returns correct select options', () => { test('Returns correct select options', () => {

View File

@@ -1,73 +0,0 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
PageSection as PFPageSection,
PageSectionVariants,
Breadcrumb,
BreadcrumbItem,
BreadcrumbHeading,
} from '@patternfly/react-core';
import { Link, Route, useRouteMatch } from 'react-router-dom';
import styled from 'styled-components';
const PageSection = styled(PFPageSection)`
padding-top: 10px;
padding-bottom: 10px;
`;
const Breadcrumbs = ({ breadcrumbConfig }) => {
const { light } = PageSectionVariants;
return (
<PageSection variant={light}>
<Breadcrumb>
<Route path="/:path">
<Crumb breadcrumbConfig={breadcrumbConfig} />
</Route>
</Breadcrumb>
</PageSection>
);
};
const Crumb = ({ breadcrumbConfig, showDivider }) => {
const match = useRouteMatch();
const crumb = breadcrumbConfig[match.url];
let crumbElement = (
<BreadcrumbItem key={match.url} showDivider={showDivider}>
<Link to={match.url}>{crumb}</Link>
</BreadcrumbItem>
);
if (match.isExact) {
crumbElement = (
<BreadcrumbHeading key="breadcrumb-heading" showDivider={showDivider}>
{crumb}
</BreadcrumbHeading>
);
}
if (!crumb) {
crumbElement = null;
}
return (
<Fragment>
{crumbElement}
<Route path={`${match.url}/:path`}>
<Crumb breadcrumbConfig={breadcrumbConfig} showDivider />
</Route>
</Fragment>
);
};
Breadcrumbs.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
Crumb.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default Breadcrumbs;

View File

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

View File

@@ -31,35 +31,31 @@ const ToolbarItem = styled(PFToolbarItem)`
// TODO: Recommend renaming this component to avoid confusion // TODO: Recommend renaming this component to avoid confusion
// with ExpandingContainer // with ExpandingContainer
class ExpandCollapse extends React.Component { function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) {
render() { return (
const { isCompact, onCompact, onExpand, i18n } = this.props; <Fragment>
<ToolbarItem>
return ( <Button
<Fragment> variant="plain"
<ToolbarItem> aria-label={i18n._(t`Collapse`)}
<Button onClick={onCompact}
variant="plain" isActive={isCompact}
aria-label={i18n._(t`Collapse`)} >
onClick={onCompact} <BarsIcon />
isActive={isCompact} </Button>
> </ToolbarItem>
<BarsIcon /> <ToolbarItem>
</Button> <Button
</ToolbarItem> variant="plain"
<ToolbarItem> aria-label={i18n._(t`Expand`)}
<Button onClick={onExpand}
variant="plain" isActive={!isCompact}
aria-label={i18n._(t`Expand`)} >
onClick={onExpand} <EqualsIcon />
isActive={!isCompact} </Button>
> </ToolbarItem>
<EqualsIcon /> </Fragment>
</Button> );
</ToolbarItem>
</Fragment>
);
}
} }
ExpandCollapse.propTypes = { ExpandCollapse.propTypes = {

View File

@@ -12,7 +12,15 @@ import {
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
function PasswordInput(props) { function PasswordInput(props) {
const { id, name, validate, isRequired, isDisabled, i18n } = props; const {
autocomplete,
id,
name,
validate,
isRequired,
isDisabled,
i18n,
} = props;
const [inputType, setInputType] = useState('password'); const [inputType, setInputType] = useState('password');
const [field, meta] = useField({ name, validate }); const [field, meta] = useField({ name, validate });
@@ -38,6 +46,7 @@ function PasswordInput(props) {
</Button> </Button>
</Tooltip> </Tooltip>
<TextInput <TextInput
autoComplete={autocomplete}
id={id} id={id}
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined} placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
{...field} {...field}
@@ -55,6 +64,7 @@ function PasswordInput(props) {
} }
PasswordInput.propTypes = { PasswordInput.propTypes = {
autocomplete: PropTypes.string,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
validate: PropTypes.func, validate: PropTypes.func,
@@ -63,6 +73,7 @@ PasswordInput.propTypes = {
}; };
PasswordInput.defaultProps = { PasswordInput.defaultProps = {
autocomplete: 'new-password',
validate: () => {}, validate: () => {},
isRequired: false, isRequired: false,
isDisabled: false, isDisabled: false,

View File

@@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) {
!launchData.ask_limit_on_launch && !launchData.ask_limit_on_launch &&
!launchData.ask_scm_branch_on_launch && !launchData.ask_scm_branch_on_launch &&
!launchData.survey_enabled && !launchData.survey_enabled &&
(!launchData.passwords_needed_to_start ||
launchData.passwords_needed_to_start.length === 0) &&
(!launchData.variables_needed_to_start || (!launchData.variables_needed_to_start ||
launchData.variables_needed_to_start.length === 0) launchData.variables_needed_to_start.length === 0)
); );
@@ -100,17 +102,20 @@ class LaunchButton extends React.Component {
async launchWithParams(params) { async launchWithParams(params) {
try { try {
const { history, resource } = this.props; const { history, resource } = this.props;
const jobPromise = let jobPromise;
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) if (resource.type === 'job_template') {
: JobTemplatesAPI.launch(resource.id, params || {}); jobPromise = JobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'workflow_job_template') {
jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {});
} else if (resource.type === 'job') {
jobPromise = JobsAPI.relaunch(resource.id, params || {});
} else if (resource.type === 'workflow_job') {
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
}
const { data: job } = await jobPromise; const { data: job } = await jobPromise;
history.push( history.push(`/jobs/${job.id}/output`);
`/${
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
}/${job.id}/output`
);
} catch (launchError) { } catch (launchError) {
this.setState({ launchError }); this.setState({ launchError });
} }
@@ -127,20 +132,15 @@ class LaunchButton extends React.Component {
readRelaunch = InventorySourcesAPI.readLaunchUpdate( readRelaunch = InventorySourcesAPI.readLaunchUpdate(
resource.inventory_source resource.inventory_source
); );
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
} else if (resource.type === 'project_update') { } else if (resource.type === 'project_update') {
// We'll need to handle the scenario where the project no longer exists // We'll need to handle the scenario where the project no longer exists
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project); readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') { } else if (resource.type === 'workflow_job') {
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id); readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') { } else if (resource.type === 'ad_hoc_command') {
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') { } else if (resource.type === 'job') {
readRelaunch = JobsAPI.readRelaunch(resource.id); readRelaunch = JobsAPI.readRelaunch(resource.id);
relaunch = JobsAPI.relaunch(resource.id);
} }
try { try {
@@ -149,11 +149,22 @@ class LaunchButton extends React.Component {
!relaunchConfig.passwords_needed_to_start || !relaunchConfig.passwords_needed_to_start ||
relaunchConfig.passwords_needed_to_start.length === 0 relaunchConfig.passwords_needed_to_start.length === 0
) { ) {
if (resource.type === 'inventory_update') {
relaunch = InventorySourcesAPI.launchUpdate(
resource.inventory_source
);
} else if (resource.type === 'project_update') {
relaunch = ProjectsAPI.launchUpdate(resource.project);
} else if (resource.type === 'workflow_job') {
relaunch = WorkflowJobsAPI.relaunch(resource.id);
} else if (resource.type === 'ad_hoc_command') {
relaunch = AdHocCommandsAPI.relaunch(resource.id);
} else if (resource.type === 'job') {
relaunch = JobsAPI.relaunch(resource.id);
}
const { data: job } = await relaunch; const { data: job } = await relaunch;
history.push(`/jobs/${job.id}/output`); history.push(`/jobs/${job.id}/output`);
} else { } else {
// TODO: restructure (async?) to send launch command after prompts
// TODO: does relaunch need different prompt treatment than launch?
this.setState({ this.setState({
showLaunchPrompt: true, showLaunchPrompt: true,
launchConfig: relaunchConfig, launchConfig: relaunchConfig,

View File

@@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils'; import { sleep } from '../../../testUtils/testUtils';
import LaunchButton from './LaunchButton'; import LaunchButton from './LaunchButton';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; import {
InventorySourcesAPI,
JobsAPI,
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
WorkflowJobTemplatesAPI,
} from '../../api';
jest.mock('../../api/models/WorkflowJobTemplates'); jest.mock('../../api');
jest.mock('../../api/models/JobTemplates');
describe('LaunchButton', () => { describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({ JobTemplatesAPI.readLaunch.mockResolvedValue({
@@ -22,10 +28,14 @@ describe('LaunchButton', () => {
}, },
}); });
const children = ({ handleLaunch }) => ( const launchButton = ({ handleLaunch }) => (
<button type="submit" onClick={() => handleLaunch()} /> <button type="submit" onClick={() => handleLaunch()} />
); );
const relaunchButton = ({ handleRelaunch }) => (
<button type="submit" onClick={() => handleRelaunch()} />
);
const resource = { const resource = {
id: 1, id: 1,
type: 'job_template', type: 'job_template',
@@ -35,7 +45,7 @@ describe('LaunchButton', () => {
test('renders the expected content', () => { test('renders the expected content', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton> <LaunchButton resource={resource}>{launchButton}</LaunchButton>
); );
expect(wrapper).toHaveLength(1); expect(wrapper).toHaveLength(1);
}); });
@@ -51,7 +61,7 @@ describe('LaunchButton', () => {
}, },
}); });
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>, <LaunchButton resource={resource}>{launchButton}</LaunchButton>,
{ {
context: { context: {
router: { history }, router: { history },
@@ -87,7 +97,7 @@ describe('LaunchButton', () => {
type: 'workflow_job_template', type: 'workflow_job_template',
}} }}
> >
{children} {launchButton}
</LaunchButton>, </LaunchButton>,
{ {
context: { context: {
@@ -100,12 +110,162 @@ describe('LaunchButton', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0); await sleep(0);
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
expect(history.location.pathname).toEqual('/jobs/workflow/9000/output'); expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch job correctly', async () => {
JobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
JobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch workflow job correctly', async () => {
WorkflowJobsAPI.readRelaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
WorkflowJobsAPI.relaunch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
ProjectsAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
ProjectsAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
project: 5,
type: 'project_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should relaunch project sync correctly', async () => {
InventorySourcesAPI.readLaunchUpdate.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
InventorySourcesAPI.launchUpdate.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
inventory_source: 5,
type: 'inventory_update',
}}
>
{relaunchButton}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
await sleep(0);
expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5);
expect(history.location.pathname).toEqual('/jobs/9000/output');
}); });
test('displays error modal after unsuccessful launch', async () => { test('displays error modal after unsuccessful launch', async () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton> <LaunchButton resource={resource}>{launchButton}</LaunchButton>
); );
JobTemplatesAPI.launch.mockRejectedValue( JobTemplatesAPI.launch.mockRejectedValue(
new Error({ new Error({

View File

@@ -19,17 +19,18 @@ function PromptModalForm({
resource, resource,
surveyConfig, surveyConfig,
}) { }) {
const { values, setTouched, validateForm } = useFormikContext(); const { setFieldTouched, values } = useFormikContext();
const { const {
steps, steps,
isReady, isReady,
validateStep,
visitStep, visitStep,
visitAllSteps, visitAllSteps,
contentError, contentError,
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
const handleSave = () => { const handleSubmit = () => {
const postValues = {}; const postValues = {};
const setValue = (key, value) => { const setValue = (key, value) => {
if (typeof value !== 'undefined' && value !== null) { if (typeof value !== 'undefined' && value !== null) {
@@ -37,6 +38,7 @@ function PromptModalForm({
} }
}; };
const surveyValues = getSurveyValues(values); const surveyValues = getSurveyValues(values);
setValue('credential_passwords', values.credential_passwords);
setValue('inventory_id', values.inventory?.id); setValue('inventory_id', values.inventory?.id);
setValue( setValue(
'credentials', 'credentials',
@@ -75,22 +77,25 @@ function PromptModalForm({
<Wizard <Wizard
isOpen isOpen
onClose={onCancel} onClose={onCancel}
onSave={handleSave} onSave={handleSubmit}
onBack={async nextStep => {
validateStep(nextStep.id);
}}
onNext={async (nextStep, prevStep) => { onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
visitAllSteps(setTouched); visitAllSteps(setFieldTouched);
} else { } else {
visitStep(prevStep.prevId); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
} }
await validateForm();
}} }}
onGoToStep={async (nextStep, prevStep) => { onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
visitAllSteps(setTouched); visitAllSteps(setFieldTouched);
} else { } else {
visitStep(prevStep.prevId); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
} }
await validateForm();
}} }}
title={i18n._(t`Prompts`)} title={i18n._(t`Prompts`)}
steps={ steps={

View File

@@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
ask_credential_on_launch: true, ask_credential_on_launch: true,
ask_scm_branch_on_launch: true, ask_scm_branch_on_launch: true,
survey_enabled: true, survey_enabled: true,
passwords_needed_to_start: ['ssh_password'],
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
resource={{
...resource,
summary_fields: {
credentials: [
{
id: 1,
},
],
},
}} }}
resource={resource}
onLaunch={noop} onLaunch={noop}
onCancel={noop} onCancel={noop}
surveyConfig={{ surveyConfig={{
@@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
const wizard = await waitForElement(wrapper, 'Wizard'); const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps'); const steps = wizard.prop('steps');
expect(steps).toHaveLength(5); expect(steps).toHaveLength(6);
expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name.props.children).toEqual('Credentials'); expect(steps[1].name.props.children).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other prompts'); expect(steps[2].name.props.children).toEqual('Credential passwords');
expect(steps[3].name.props.children).toEqual('Survey'); expect(steps[3].name.props.children).toEqual('Other prompts');
expect(steps[4].name.props.children).toEqual('Preview'); expect(steps[4].name.props.children).toEqual('Survey');
expect(steps[5].name.props.children).toEqual('Preview');
}); });
test('should add inventory step', async () => { test('should add inventory step', async () => {

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Form } from '@patternfly/react-core';
import { useFormikContext } from 'formik';
import { PasswordField } from '../../FormField';
function CredentialPasswordsStep({ launchConfig, i18n }) {
const {
values: { credentials },
} = useFormikContext();
const vaultsThatPrompt = [];
let showcredentialPasswordSsh = false;
let showcredentialPasswordPrivilegeEscalation = false;
let showcredentialPasswordPrivateKeyPassphrase = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (password === 'ssh_password') {
showcredentialPasswordSsh = true;
} else if (password === 'become_password') {
showcredentialPasswordPrivilegeEscalation = true;
} else if (password === 'ssh_key_unlock') {
showcredentialPasswordPrivateKeyPassphrase = true;
} else if (password.startsWith('vault_password')) {
const vaultId = password.split(/\.(.+)/)[1] || '';
vaultsThatPrompt.push(vaultId);
}
});
} else if (credentials) {
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
if (
launchConfigCredential.passwords_needed.includes('ssh_password')
) {
showcredentialPasswordSsh = true;
}
if (
launchConfigCredential.passwords_needed.includes('become_password')
) {
showcredentialPasswordPrivilegeEscalation = true;
}
if (
launchConfigCredential.passwords_needed.includes('ssh_key_unlock')
) {
showcredentialPasswordPrivateKeyPassphrase = true;
}
const vaultPasswordIds = launchConfigCredential.passwords_needed
.filter(passwordNeeded =>
passwordNeeded.startsWith('vault_password')
)
.map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '');
vaultsThatPrompt.push(...vaultPasswordIds);
}
} else {
if (credential?.inputs?.password === 'ASK') {
showcredentialPasswordSsh = true;
}
if (credential?.inputs?.become_password === 'ASK') {
showcredentialPasswordPrivilegeEscalation = true;
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
showcredentialPasswordPrivateKeyPassphrase = true;
}
if (credential?.inputs?.vault_password === 'ASK') {
vaultsThatPrompt.push(credential.inputs.vault_id);
}
}
});
}
return (
<Form>
{showcredentialPasswordSsh && (
<PasswordField
id="launch-ssh-password"
label={i18n._(t`SSH password`)}
name="credential_passwords.ssh_password"
isRequired
/>
)}
{showcredentialPasswordPrivateKeyPassphrase && (
<PasswordField
id="launch-private-key-passphrase"
label={i18n._(t`Private key passphrase`)}
name="credential_passwords.ssh_key_unlock"
isRequired
/>
)}
{showcredentialPasswordPrivilegeEscalation && (
<PasswordField
id="launch-privilege-escalation-password"
label={i18n._(t`Privilege escalation password`)}
name="credential_passwords.become_password"
isRequired
/>
)}
{vaultsThatPrompt.map(credId => (
<PasswordField
id={`launch-vault-password-${credId}`}
key={credId}
label={
credId === ''
? i18n._(t`Vault password`)
: i18n._(t`Vault password | ${credId}`)
}
name={`credential_passwords['vault_password${
credId !== '' ? `.${credId}` : ''
}']`}
isRequired
/>
))}
</Form>
);
}
export default withI18n()(CredentialPasswordsStep);

View File

@@ -0,0 +1,603 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import CredentialPasswordsStep from './CredentialPasswordsStep';
describe('CredentialPasswordsStep', () => {
describe('JT default credentials (no credential replacement) and creds are promptable', () => {
test('should render ssh password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['ssh_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['become_password'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when JT has default machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
defaults: {
ask_credential_on_launch: true,
credentials: [
{
id: 1,
passwords_needed: ['ssh_key_unlock'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when JT has default vault cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
test('should render all password field when JT has default vault cred and machine cred', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
},
{
id: 2,
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [
{
id: 1,
passwords_needed: [
'ssh_password',
'become_password',
'ssh_key_unlock',
],
},
{
id: 2,
passwords_needed: ['vault_password.1'],
},
],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-1')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are promptable', () => {
test('should render ssh password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: null,
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: 'ASK',
ssh_key_unlock: null,
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when replacement machine cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: 'ASK',
vault_password: null,
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when replacement vault cred prompts for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when replacement vault and machine creds prompt for it', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [
{
id: 1,
inputs: {
password: 'ASK',
become_password: 'ASK',
ssh_key_unlock: 'ASK',
},
},
{
id: 2,
inputs: {
password: null,
become_password: null,
ssh_key_unlock: null,
vault_password: 'ASK',
vault_id: 'foobar',
},
},
],
}}
>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: true,
defaults: {
credentials: [],
},
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
describe('Credentials have been replaced and creds are not promptable', () => {
test('should render ssh password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render become password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['become_password'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render private key passphrase field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['ssh_key_unlock'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(0);
});
test('should render vault password field when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: ['vault_password.foobar'],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(0);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(0);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
test('should render all password fields when required', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik initialValues={{}}>
<CredentialPasswordsStep
launchConfig={{
ask_credential_on_launch: false,
passwords_needed_to_start: [
'ssh_password',
'become_password',
'ssh_key_unlock',
'vault_password.foobar',
],
}}
/>
</Formik>
);
});
expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-private-key-passphrase')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-privilege-escalation-password')
).toHaveLength(1);
expect(
wrapper.find('PasswordField[id^="launch-vault-password-"]')
).toHaveLength(1);
expect(
wrapper.find('PasswordField#launch-vault-password-foobar')
).toHaveLength(1);
});
});
});

View File

@@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
@@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
}); });
function InventoryStep({ i18n }) { function InventoryStep({ i18n }) {
const [field, , helpers] = useField({ const [field, meta, helpers] = useField({
name: 'inventory', name: 'inventory',
}); });
const history = useHistory(); const history = useHistory();
const { const {
@@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
} }
return ( return (
<OptionsList <>
value={field.value ? [field.value] : []} <OptionsList
options={inventories} value={field.value ? [field.value] : []}
optionCount={count} options={inventories}
searchColumns={[ optionCount={count}
{ searchColumns={[
name: i18n._(t`Name`), {
key: 'name__icontains', name: i18n._(t`Name`),
isDefault: true, key: 'name__icontains',
}, isDefault: true,
{ },
name: i18n._(t`Created By (Username)`), {
key: 'created_by__username__icontains', name: i18n._(t`Created By (Username)`),
}, key: 'created_by__username__icontains',
{ },
name: i18n._(t`Modified By (Username)`), {
key: 'modified_by__username__icontains', name: i18n._(t`Modified By (Username)`),
}, key: 'modified_by__username__icontains',
]} },
sortColumns={[ ]}
{ sortColumns={[
name: i18n._(t`Name`), {
key: 'name', name: i18n._(t`Name`),
}, key: 'name',
]} },
searchableKeys={searchableKeys} ]}
relatedSearchableKeys={relatedSearchableKeys} searchableKeys={searchableKeys}
header={i18n._(t`Inventory`)} relatedSearchableKeys={relatedSearchableKeys}
name="inventory" header={i18n._(t`Inventory`)}
qsConfig={QS_CONFIG} name="inventory"
readOnly qsConfig={QS_CONFIG}
selectItem={helpers.setValue} readOnly
deselectItem={() => field.onChange(null)} selectItem={helpers.setValue}
/> deselectItem={() => field.onChange(null)}
/>
{meta.touched && meta.error && (
<Alert variant="danger" isInline title={meta.error} />
)}
</>
); );
} }

View File

@@ -0,0 +1,254 @@
import React from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import CredentialPasswordsStep from './CredentialPasswordsStep';
import StepName from './StepName';
const STEP_ID = 'credentialPasswords';
const isValueMissing = val => {
return !val || val === '';
};
export default function useCredentialPasswordsStep(
launchConfig,
i18n,
showStep,
visitedSteps
) {
const { values, setFieldError } = useFormikContext();
const hasError =
Object.keys(visitedSteps).includes(STEP_ID) &&
checkForError(launchConfig, values);
return {
step: showStep
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="credential-passwords-step">
{i18n._(t`Credential passwords`)}
</StepName>
),
component: (
<CredentialPasswordsStep launchConfig={launchConfig} i18n={i18n} />
),
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, values.credentials),
isReady: true,
contentError: null,
hasError,
setTouched: setFieldTouched => {
Object.keys(values.credential_passwords).forEach(credentialValueKey =>
setFieldTouched(
`credential_passwords['${credentialValueKey}']`,
true,
false
)
);
},
validate: () => {
const setPasswordFieldError = fieldName => {
setFieldError(fieldName, i18n._(t`This field may not be blank`));
};
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
setPasswordFieldError(`credential_passwords['${password}']`);
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
setPasswordFieldError('credential_passwords.ssh_password');
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
setPasswordFieldError('credential_passwords.become_password');
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
setPasswordFieldError('credential_passwords.ssh_key_unlock');
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
setPasswordFieldError(
`credential_passwords['vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}']`
);
}
}
});
}
},
};
}
function getInitialValues(launchConfig, selectedCredentials = []) {
const initialValues = {
credential_passwords: {},
};
if (!launchConfig) {
return initialValues;
}
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
initialValues.credential_passwords[password] = '';
});
return initialValues;
}
selectedCredentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
initialValues.credential_passwords[password] = '';
});
}
} else {
if (credential?.inputs?.password === 'ASK') {
initialValues.credential_passwords.ssh_password = '';
}
if (credential?.inputs?.become_password === 'ASK') {
initialValues.credential_passwords.become_password = '';
}
if (credential?.inputs?.ssh_key_unlock === 'ASK') {
initialValues.credential_passwords.ssh_key_unlock = '';
}
if (credential?.inputs?.vault_password === 'ASK') {
if (!credential.inputs.vault_id || credential.inputs.vault_id === '') {
initialValues.credential_passwords.vault_password = '';
} else {
initialValues.credential_passwords[
`vault_password.${credential.inputs.vault_id}`
] = '';
}
}
}
});
return initialValues;
}
function checkForError(launchConfig, values) {
let hasError = false;
if (
!launchConfig.ask_credential_on_launch &&
launchConfig.passwords_needed_to_start
) {
launchConfig.passwords_needed_to_start.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
} else if (values.credentials) {
values.credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
launchConfigCredential.passwords_needed.forEach(password => {
if (isValueMissing(values.credential_passwords[password])) {
hasError = true;
}
});
}
} else {
if (
credential?.inputs?.password === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_password)
) {
hasError = true;
}
if (
credential?.inputs?.become_password === 'ASK' &&
isValueMissing(values.credential_passwords.become_password)
) {
hasError = true;
}
if (
credential?.inputs?.ssh_key_unlock === 'ASK' &&
isValueMissing(values.credential_passwords.ssh_key_unlock)
) {
hasError = true;
}
if (
credential?.inputs?.vault_password === 'ASK' &&
isValueMissing(
values.credential_passwords[
`vault_password${
credential.inputs.vault_id !== ''
? `.${credential.inputs.vault_id}`
: ''
}`
]
)
) {
hasError = true;
}
}
});
}
return hasError;
}

View File

@@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) {
return { return {
step: getStep(launchConfig, i18n), step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, hasError: false,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ setFieldTouched('credentials', true, false);
credentials: true,
});
}, },
validate: () => {},
}; };
} }

View File

@@ -12,20 +12,27 @@ export default function useInventoryStep(
i18n, i18n,
visitedSteps visitedSteps
) { ) {
const [, meta] = useField('inventory'); const [, meta, helpers] = useField('inventory');
const formError = const formError =
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); !resource || resource?.type === 'workflow_job_template'
? false
: Object.keys(visitedSteps).includes(STEP_ID) &&
meta.touched &&
!meta.value;
return { return {
step: getStep(launchConfig, i18n, formError), step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: launchConfig.ask_inventory_on_launch && formError, hasError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ setFieldTouched('inventory', true, false);
inventory: true, },
}); validate: () => {
if (meta.touched && !meta.value && resource.type === 'job_template') {
helpers.setError(i18n._(t`An inventory must be selected`));
}
}, },
}; };
} }

View File

@@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) {
initialValues: getInitialValues(launchConfig, resource), initialValues: getInitialValues(launchConfig, resource),
isReady: true, isReady: true,
contentError: null, contentError: null,
formError: null, hasError: false,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
setFieldsTouched({ [
job_type: true, 'job_type',
limit: true, 'limit',
verbosity: true, 'verbosity',
diff_mode: true, 'diff_mode',
job_tags: true, 'job_tags',
skip_tags: true, 'skip_tags',
extra_vars: true, 'extra_vars',
}); ].forEach(field => setFieldTouched(field, true, false));
}, },
validate: () => {},
}; };
} }

View File

@@ -35,9 +35,9 @@ export default function usePreviewStep(
} }
: null, : null,
initialValues: {}, initialValues: {},
validate: () => ({}),
isReady: true, isReady: true,
error: null, error: null,
setTouched: () => {}, setTouched: () => {},
validate: () => {},
}; };
} }

View File

@@ -13,89 +13,51 @@ export default function useSurveyStep(
i18n, i18n,
visitedSteps visitedSteps
) { ) {
const { values } = useFormikContext(); const { setFieldError, values } = useFormikContext();
const errors = {}; const hasError =
const validate = () => { Object.keys(visitedSteps).includes(STEP_ID) &&
if (!launchConfig.survey_enabled || !surveyConfig?.spec) { checkForError(launchConfig, surveyConfig, values);
return {};
}
surveyConfig.spec.forEach(question => {
const errMessage = validateField(
question,
values[`survey_${question.variable}`],
i18n
);
if (errMessage) {
errors[`survey_${question.variable}`] = errMessage;
}
});
return errors;
};
const formError = Object.keys(validate()).length > 0;
return { return {
step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), step: launchConfig.survey_enabled
? {
id: STEP_ID,
name: (
<StepName hasErrors={hasError} id="survey-step">
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
}
: null,
initialValues: getInitialValues(launchConfig, surveyConfig, resource), initialValues: getInitialValues(launchConfig, surveyConfig, resource),
validate,
surveyConfig, surveyConfig,
isReady: true, isReady: true,
contentError: null, contentError: null,
formError, hasError,
setTouched: setFieldsTouched => { setTouched: setFieldTouched => {
if (!surveyConfig?.spec) { if (!surveyConfig?.spec) {
return; return;
} }
const fields = {};
surveyConfig.spec.forEach(question => { surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true; setFieldTouched(`survey_${question.variable}`, true, false);
}); });
setFieldsTouched(fields);
}, },
}; validate: () => {
} if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
function validateField(question, value, i18n) { const errMessage = validateSurveyField(
const isTextField = ['text', 'textarea'].includes(question.type); question,
const isNumeric = ['integer', 'float'].includes(question.type); values[`survey_${question.variable}`],
if (isTextField && (value || value === 0)) { i18n
if (question.min && value.length < question.min) { );
return i18n._(t`This field must be at least ${question.min} characters`); if (errMessage) {
} setFieldError(`survey_${question.variable}`, errMessage);
if (question.max && value.length > question.max) { }
return i18n._(t`This field must not exceed ${question.max} characters`); });
} }
} },
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
if (!launchConfig.survey_enabled) {
return null;
}
return {
id: STEP_ID,
name: (
<StepName
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
Object.keys(validate()).length
}
id="survey-step"
>
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
enableNext: true,
}; };
} }
@@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
return values; return values;
} }
function validateSurveyField(question, value, i18n) {
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (question.min && value.length < question.min) {
return i18n._(t`This field must be at least ${question.min} characters`);
}
if (question.max && value.length > question.max) {
return i18n._(t`This field must not exceed ${question.max} characters`);
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
return i18n._(
t`This field must be a number and have a value between ${question.min} and ${question.max}`
);
}
}
if (question.required && !value && value !== 0) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function checkForError(launchConfig, surveyConfig, values) {
let hasError = false;
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
const value = values[`survey_${question.variable}`];
const isTextField = ['text', 'textarea'].includes(question.type);
const isNumeric = ['integer', 'float'].includes(question.type);
if (isTextField && (value || value === 0)) {
if (
(question.min && value.length < question.min) ||
(question.max && value.length > question.max)
) {
hasError = true;
}
}
if (isNumeric && (value || value === 0)) {
if (value < question.min || value > question.max) {
hasError = true;
}
}
if (question.required && !value && value !== 0) {
hasError = true;
}
});
}
return hasError;
}

View File

@@ -2,10 +2,43 @@ import { useState, useEffect } from 'react';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import useInventoryStep from './steps/useInventoryStep'; import useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep'; import useCredentialsStep from './steps/useCredentialsStep';
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep'; import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep'; import usePreviewStep from './steps/usePreviewStep';
function showCredentialPasswordsStep(credentials = [], launchConfig) {
if (
!launchConfig?.ask_credential_on_launch &&
launchConfig?.passwords_needed_to_start
) {
return launchConfig.passwords_needed_to_start.length > 0;
}
let credentialPasswordStepRequired = false;
credentials.forEach(credential => {
if (!credential.inputs) {
const launchConfigCredential = launchConfig.defaults.credentials.find(
defaultCred => defaultCred.id === credential.id
);
if (launchConfigCredential?.passwords_needed.length > 0) {
credentialPasswordStepRequired = true;
}
} else if (
credential?.inputs?.password === 'ASK' ||
credential?.inputs?.become_password === 'ASK' ||
credential?.inputs?.ssh_key_unlock === 'ASK' ||
credential?.inputs?.vault_password === 'ASK'
) {
credentialPasswordStepRequired = true;
}
});
return credentialPasswordStepRequired;
}
export default function useLaunchSteps( export default function useLaunchSteps(
launchConfig, launchConfig,
surveyConfig, surveyConfig,
@@ -14,14 +47,21 @@ export default function useLaunchSteps(
) { ) {
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const { touched, values: formikValues } = useFormikContext();
const steps = [ const steps = [
useInventoryStep(launchConfig, resource, i18n, visited), useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n), useCredentialsStep(launchConfig, resource, i18n),
useCredentialPasswordsStep(
launchConfig,
i18n,
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
visited
),
useOtherPromptsStep(launchConfig, resource, i18n), useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
]; ];
const { resetForm } = useFormikContext(); const { resetForm } = useFormikContext();
const hasErrors = steps.some(step => step.formError); const hasErrors = steps.some(step => step.hasError);
steps.push( steps.push(
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true) usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
@@ -38,16 +78,47 @@ export default function useLaunchSteps(
...cur.initialValues, ...cur.initialValues,
}; };
}, {}); }, {});
const newFormValues = { ...initialValues };
Object.keys(formikValues).forEach(formikValueKey => {
if (
formikValueKey === 'credential_passwords' &&
Object.prototype.hasOwnProperty.call(
newFormValues,
'credential_passwords'
)
) {
const formikCredentialPasswords = formikValues.credential_passwords;
Object.keys(formikCredentialPasswords).forEach(
credentialPasswordValueKey => {
if (
Object.prototype.hasOwnProperty.call(
newFormValues.credential_passwords,
credentialPasswordValueKey
)
) {
newFormValues.credential_passwords[credentialPasswordValueKey] =
formikCredentialPasswords[credentialPasswordValueKey];
}
}
);
} else if (
Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey)
) {
newFormValues[formikValueKey] = formikValues[formikValueKey];
}
});
resetForm({ resetForm({
values: { values: newFormValues,
...initialValues, touched,
},
}); });
setIsReady(true); setIsReady(true);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsAreReady]); }, [formikValues.credentials, stepsAreReady]);
const stepWithError = steps.find(s => s.contentError); const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null; const contentError = stepWithError ? stepWithError.contentError : null;
@@ -55,20 +126,26 @@ export default function useLaunchSteps(
return { return {
steps: pfSteps, steps: pfSteps,
isReady, isReady,
visitStep: stepId => validateStep: stepId => {
steps.find(s => s?.step?.id === stepId).validate();
},
visitStep: (prevStepId, setFieldTouched) => {
setVisited({ setVisited({
...visited, ...visited,
[stepId]: true, [prevStepId]: true,
}), });
visitAllSteps: setFieldsTouched => { steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({ setVisited({
inventory: true, inventory: true,
credentials: true, credentials: true,
credentialPasswords: true,
other: true, other: true,
survey: true, survey: true,
preview: true, preview: true,
}); });
steps.forEach(s => s.setTouched(setFieldsTouched)); steps.forEach(s => s.setTouched(setFieldTouched));
}, },
contentError, contentError,
}; };

View File

@@ -85,7 +85,12 @@ class ListHeader extends React.Component {
pushHistoryState(params) { pushHistoryState(params) {
const { history, qsConfig } = this.props; const { history, qsConfig } = this.props;
const { pathname } = history.location; const { pathname } = history.location;
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
} }

View File

@@ -12,6 +12,7 @@ import {
FormGroup, FormGroup,
InputGroup, InputGroup,
Modal, Modal,
Tooltip,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
import Popover from '../Popover'; import Popover from '../Popover';
@@ -243,6 +244,36 @@ function HostFilterLookup({
}); });
}; };
const renderLookup = () => (
<InputGroup onBlur={onBlur}>
<Button
aria-label={i18n._(t`Search`)}
id="host-filter"
isDisabled={isDisabled}
onClick={handleOpenModal}
variant={ButtonVariant.control}
>
<SearchIcon />
</Button>
<ChipHolder className="pf-c-form-control">
{searchColumns.map(({ name, key }) => (
<ChipGroup
categoryName={name}
key={name}
numChips={5}
totalChips={chips[key]?.chips?.length || 0}
>
{chips[key]?.chips?.map(chip => (
<Chip key={chip.key} isReadOnly>
{chip.node}
</Chip>
))}
</ChipGroup>
))}
</ChipHolder>
</InputGroup>
);
return ( return (
<FormGroup <FormGroup
fieldId="host-filter" fieldId="host-filter"
@@ -261,33 +292,17 @@ function HostFilterLookup({
/> />
} }
> >
<InputGroup onBlur={onBlur}> {isDisabled ? (
<Button <Tooltip
aria-label={i18n._(t`Search`)} content={i18n._(
id="host-filter" t`Please select an organization before editing the host filter`
isDisabled={isDisabled} )}
onClick={handleOpenModal}
variant={ButtonVariant.control}
> >
<SearchIcon /> {renderLookup()}
</Button> </Tooltip>
<ChipHolder className="pf-c-form-control"> ) : (
{searchColumns.map(({ name, key }) => ( renderLookup()
<ChipGroup )}
categoryName={name}
key={name}
numChips={5}
totalChips={chips[key]?.chips?.length || 0}
>
{chips[key]?.chips?.map(chip => (
<Chip key={chip.key} isReadOnly>
{chip.node}
</Chip>
))}
</ChipGroup>
))}
</ChipHolder>
</InputGroup>
<Modal <Modal
aria-label={i18n._(t`Lookup modal`)} aria-label={i18n._(t`Lookup modal`)}
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@@ -61,7 +61,12 @@ function PaginatedDataList({
}; };
const pushHistoryState = params => { const pushHistoryState = params => {
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
}; };

View File

@@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) {
order_by: order === 'asc' ? key : `-${key}`, order_by: order === 'asc' ? key : `-${key}`,
page: null, page: null,
}); });
const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams); const nonNamespacedParams = parseQueryString({}, history.location.search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
newParams,
nonNamespacedParams
);
history.push( history.push(
encodedParams encodedParams
? `${location.pathname}?${encodedParams}` ? `${location.pathname}?${encodedParams}`

View File

@@ -40,8 +40,13 @@ function PaginatedTable({
const history = useHistory(); const history = useHistory();
const pushHistoryState = params => { const pushHistoryState = params => {
const { pathname } = history.location; const { pathname, search } = history.location;
const encodedParams = encodeNonDefaultQueryString(qsConfig, params); const nonNamespacedParams = parseQueryString({}, search);
const encodedParams = encodeNonDefaultQueryString(
qsConfig,
params,
nonNamespacedParams
);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname); history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
}; };

View File

@@ -7,69 +7,71 @@ import { t } from '@lingui/macro';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { Role } from '../../types'; import { Role } from '../../types';
class DeleteRoleConfirmationModal extends React.Component { function DeleteRoleConfirmationModal({
static propTypes = { role,
role: Role.isRequired, username,
username: string, onCancel,
onCancel: func.isRequired, onConfirm,
onConfirm: func.isRequired, i18n,
}; }) {
const isTeamRole = () => {
static defaultProps = {
username: '',
};
isTeamRole() {
const { role } = this.props;
return typeof role.team_id !== 'undefined'; return typeof role.team_id !== 'undefined';
} };
render() { const title = i18n._(
const { role, username, onCancel, onConfirm, i18n } = this.props; t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
const title = i18n._( );
t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access` return (
); <AlertModal
return ( variant="danger"
<AlertModal title={title}
variant="danger" isOpen
title={title} onClose={onCancel}
isOpen actions={[
onClose={onCancel} <Button
actions={[ key="delete"
<Button variant="danger"
key="delete" aria-label={i18n._(t`Confirm delete`)}
variant="danger" onClick={onConfirm}
aria-label={i18n._(t`Confirm delete`)} >
onClick={onConfirm} {i18n._(t`Delete`)}
> </Button>,
{i18n._(t`Delete`)} <Button key="cancel" variant="secondary" onClick={onCancel}>
</Button>, {i18n._(t`Cancel`)}
<Button key="cancel" variant="secondary" onClick={onCancel}> </Button>,
{i18n._(t`Cancel`)} ]}
</Button>, >
]} {isTeamRole() ? (
> <Fragment>
{this.isTeamRole() ? ( {i18n._(
<Fragment> t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.`
{i18n._( )}
t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.` <br />
)} <br />
<br /> {i18n._(
<br /> t`If you only want to remove access for this particular user, please remove them from the team.`
{i18n._( )}
t`If you only want to remove access for this particular user, please remove them from the team.` </Fragment>
)} ) : (
</Fragment> <Fragment>
) : ( {i18n._(
<Fragment> t`Are you sure you want to remove ${role.name} access from ${username}?`
{i18n._( )}
t`Are you sure you want to remove ${role.name} access from ${username}?` </Fragment>
)} )}
</Fragment> </AlertModal>
)} );
</AlertModal>
);
}
} }
DeleteRoleConfirmationModal.propTypes = {
role: Role.isRequired,
username: string,
onCancel: func.isRequired,
onConfirm: func.isRequired,
};
DeleteRoleConfirmationModal.defaultProps = {
username: '',
};
export default withI18n()(DeleteRoleConfirmationModal); export default withI18n()(DeleteRoleConfirmationModal);

View File

@@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
setDeletionRole(role); setDeletionRole(role);
setShowDeleteModal(true); setShowDeleteModal(true);
}} }}
i18n={i18n}
/> />
)} )}
/> />

View File

@@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)`
align-items: start; align-items: start;
`; `;
class ResourceAccessListItem extends React.Component { function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) {
static propTypes = { ResourceAccessListItem.propTypes = {
accessRecord: AccessRecord.isRequired, accessRecord: AccessRecord.isRequired,
onRoleDelete: func.isRequired, onRoleDelete: func.isRequired,
}; };
constructor(props) { const getRoleLists = () => {
super(props);
this.renderChip = this.renderChip.bind(this);
}
getRoleLists() {
const { accessRecord } = this.props;
const teamRoles = []; const teamRoles = [];
const userRoles = []; const userRoles = [];
@@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component {
accessRecord.summary_fields.direct_access.map(sort); accessRecord.summary_fields.direct_access.map(sort);
accessRecord.summary_fields.indirect_access.map(sort); accessRecord.summary_fields.indirect_access.map(sort);
return [teamRoles, userRoles]; return [teamRoles, userRoles];
} };
renderChip(role) { const renderChip = role => {
const { accessRecord, onRoleDelete } = this.props;
return ( return (
<Chip <Chip
key={role.id} key={role.id}
@@ -67,79 +60,76 @@ class ResourceAccessListItem extends React.Component {
{role.name} {role.name}
</Chip> </Chip>
); );
} };
render() { const [teamRoles, userRoles] = getRoleLists();
const { accessRecord, i18n } = this.props;
const [teamRoles, userRoles] = this.getRoleLists();
return ( return (
<DataListItem <DataListItem
aria-labelledby="access-list-item" aria-labelledby="access-list-item"
key={accessRecord.id} key={accessRecord.id}
id={`${accessRecord.id}`} id={`${accessRecord.id}`}
> >
<DataListItemRow> <DataListItemRow>
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key="name"> <DataListCell key="name">
{accessRecord.username && ( {accessRecord.username && (
<TextContent> <TextContent>
{accessRecord.id ? ( {accessRecord.id ? (
<Text component={TextVariants.h6}> <Text component={TextVariants.h6}>
<Link <Link
to={{ pathname: `/users/${accessRecord.id}/details` }} to={{ pathname: `/users/${accessRecord.id}/details` }}
css="font-weight: bold" css="font-weight: bold"
> >
{accessRecord.username}
</Link>
</Text>
) : (
<Text component={TextVariants.h6} css="font-weight: bold">
{accessRecord.username} {accessRecord.username}
</Text> </Link>
)} </Text>
</TextContent> ) : (
)} <Text component={TextVariants.h6} css="font-weight: bold">
{accessRecord.first_name || accessRecord.last_name ? ( {accessRecord.username}
<DetailList stacked> </Text>
<Detail )}
label={i18n._(t`Name`)} </TextContent>
value={`${accessRecord.first_name} ${accessRecord.last_name}`} )}
/> {accessRecord.first_name || accessRecord.last_name ? (
</DetailList>
) : null}
</DataListCell>,
<DataListCell key="roles">
<DetailList stacked> <DetailList stacked>
{userRoles.length > 0 && ( <Detail
<Detail label={i18n._(t`Name`)}
label={i18n._(t`User Roles`)} value={`${accessRecord.first_name} ${accessRecord.last_name}`}
value={ />
<ChipGroup numChips={5} totalChips={userRoles.length}>
{userRoles.map(this.renderChip)}
</ChipGroup>
}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={i18n._(t`Team Roles`)}
value={
<ChipGroup numChips={5} totalChips={teamRoles.length}>
{teamRoles.map(this.renderChip)}
</ChipGroup>
}
/>
)}
</DetailList> </DetailList>
</DataListCell>, ) : null}
]} </DataListCell>,
/> <DataListCell key="roles">
</DataListItemRow> <DetailList stacked>
</DataListItem> {userRoles.length > 0 && (
); <Detail
} label={i18n._(t`User Roles`)}
value={
<ChipGroup numChips={5} totalChips={userRoles.length}>
{userRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={i18n._(t`Team Roles`)}
value={
<ChipGroup numChips={5} totalChips={teamRoles.length}>
{teamRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
</DetailList>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
} }
export default withI18n()(ResourceAccessListItem); export default withI18n()(ResourceAccessListItem);

View File

@@ -0,0 +1,135 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
PageSection,
PageSectionVariants,
Breadcrumb,
BreadcrumbItem,
Title,
Tooltip,
} from '@patternfly/react-core';
import { HistoryIcon } from '@patternfly/react-icons';
import { Link, Route, useRouteMatch } from 'react-router-dom';
const ScreenHeader = ({ breadcrumbConfig, i18n, streamType }) => {
const { light } = PageSectionVariants;
const oneCrumbMatch = useRouteMatch({
path: Object.keys(breadcrumbConfig)[0],
strict: true,
});
const isOnlyOneCrumb = oneCrumbMatch && oneCrumbMatch.isExact;
return (
<PageSection variant={light}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
{!isOnlyOneCrumb && (
<Breadcrumb>
<Route path="/:path">
<Crumb breadcrumbConfig={breadcrumbConfig} />
</Route>
</Breadcrumb>
)}
<div
style={{
minHeight: '31px',
}}
>
<Route path="/:path">
<ActualTitle breadcrumbConfig={breadcrumbConfig} />
</Route>
</div>
</div>
{streamType !== 'none' && (
<div>
<Tooltip content={i18n._(t`View activity stream`)} position="top">
<Button
aria-label={i18n._(t`View activity stream`)}
variant="plain"
component={Link}
to={`/activity_stream${
streamType ? `?type=${streamType}` : ''
}`}
>
<HistoryIcon />
</Button>
</Tooltip>
</div>
)}
</div>
</PageSection>
);
};
const ActualTitle = ({ breadcrumbConfig }) => {
const match = useRouteMatch();
const title = breadcrumbConfig[match.url];
let titleElement;
if (match.isExact) {
titleElement = (
<Title size="2xl" headingLevel="h2">
{title}
</Title>
);
}
if (!title) {
titleElement = null;
}
return (
<Fragment>
{titleElement}
<Route path={`${match.url}/:path`}>
<ActualTitle breadcrumbConfig={breadcrumbConfig} />
</Route>
</Fragment>
);
};
const Crumb = ({ breadcrumbConfig, showDivider }) => {
const match = useRouteMatch();
const crumb = breadcrumbConfig[match.url];
let crumbElement = (
<BreadcrumbItem key={match.url} showDivider={showDivider}>
<Link to={match.url}>{crumb}</Link>
</BreadcrumbItem>
);
if (match.isExact) {
crumbElement = null;
}
if (!crumb) {
crumbElement = null;
}
return (
<Fragment>
{crumbElement}
<Route path={`${match.url}/:path`}>
<Crumb breadcrumbConfig={breadcrumbConfig} showDivider />
</Route>
</Fragment>
);
};
ScreenHeader.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
Crumb.propTypes = {
breadcrumbConfig: PropTypes.objectOf(PropTypes.string).isRequired,
};
export default withI18n()(ScreenHeader);

View File

@@ -1,9 +1,15 @@
import React from 'react'; import React from 'react';
import { mount } from 'enzyme';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import Breadcrumbs from './Breadcrumbs';
describe('<Breadcrumb />', () => { import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ScreenHeader from './ScreenHeader';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ScreenHeader />', () => {
let breadcrumbWrapper; let breadcrumbWrapper;
let breadcrumb; let breadcrumb;
let breadcrumbItem; let breadcrumbItem;
@@ -17,15 +23,15 @@ describe('<Breadcrumb />', () => {
}; };
const findChildren = () => { const findChildren = () => {
breadcrumb = breadcrumbWrapper.find('Breadcrumb'); breadcrumb = breadcrumbWrapper.find('ScreenHeader');
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem'); breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading'); breadcrumbHeading = breadcrumbWrapper.find('Title');
}; };
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
breadcrumbWrapper = mount( breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}> <MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
<Breadcrumbs breadcrumbConfig={config} /> <ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</MemoryRouter> </MemoryRouter>
); );
@@ -51,9 +57,9 @@ describe('<Breadcrumb />', () => {
]; ];
routes.forEach(([location, crumbLength]) => { routes.forEach(([location, crumbLength]) => {
breadcrumbWrapper = mount( breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={[location]}> <MemoryRouter initialEntries={[location]}>
<Breadcrumbs breadcrumbConfig={config} /> <ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</MemoryRouter> </MemoryRouter>
); );

View File

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

View File

@@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom'; import { useLocation, withRouter } from 'react-router-dom';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { import {
Button, Button,
@@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div`
border-bottom-color: var(--pf-global--BorderColor--200); border-bottom-color: var(--pf-global--BorderColor--200);
`; `;
class Sort extends React.Component { function Sort({ columns, qsConfig, onSort, i18n }) {
constructor(props) { const location = useLocation();
super(props); const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
let sortKey; let sortKey;
let sortOrder; let sortOrder;
let isNumeric; let isNumeric;
const { qsConfig, location } = this.props; const queryParams = parseQueryString(qsConfig, location.search);
const queryParams = parseQueryString(qsConfig, location.search); if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
if (queryParams.order_by && queryParams.order_by.startsWith('-')) { sortKey = queryParams.order_by.substr(1);
sortKey = queryParams.order_by.substr(1); sortOrder = 'descending';
sortOrder = 'descending'; } else if (queryParams.order_by) {
} else if (queryParams.order_by) { sortKey = queryParams.order_by;
sortKey = queryParams.order_by; sortOrder = 'ascending';
sortOrder = 'ascending';
}
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true;
} else {
isNumeric = false;
}
this.state = {
isSortDropdownOpen: false,
sortKey,
sortOrder,
isNumeric,
};
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
this.handleSort = this.handleSort.bind(this);
} }
handleDropdownToggle(isSortDropdownOpen) { if (qsConfig.integerFields.find(field => field === sortKey)) {
this.setState({ isSortDropdownOpen }); isNumeric = true;
} else {
isNumeric = false;
} }
handleDropdownSelect({ target }) { const handleDropdownToggle = isOpen => {
const { columns, onSort, qsConfig } = this.props; setIsSortDropdownOpen(isOpen);
const { sortOrder } = this.state; };
const handleDropdownSelect = ({ target }) => {
const { innerText } = target; const { innerText } = target;
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText); const [{ key }] = columns.filter(({ name }) => name === innerText);
sortKey = key;
let isNumeric; if (qsConfig.integerFields.find(field => field === key)) {
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true; isNumeric = true;
} else { } else {
isNumeric = false; isNumeric = false;
} }
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric }); setIsSortDropdownOpen(false);
onSort(sortKey, sortOrder); onSort(sortKey, sortOrder);
} };
handleSort() { const handleSort = () => {
const { onSort } = this.props; onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending');
const { sortKey, sortOrder } = this.state; };
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
this.setState({ sortOrder: newSortOrder });
onSort(sortKey, newSortOrder);
}
render() { const { up } = DropdownPosition;
const { up } = DropdownPosition;
const { columns, i18n } = this.props;
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
const defaultSortedColumn = columns.find(({ key }) => key === sortKey); const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
if (!defaultSortedColumn) { if (!defaultSortedColumn) {
throw new Error( throw new Error(
'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />' 'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />'
);
}
const sortedColumnName = defaultSortedColumn?.name;
const sortDropdownItems = columns
.filter(({ key }) => key !== sortKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
let SortIcon;
if (isNumeric) {
SortIcon =
sortOrder === 'ascending'
? SortNumericDownIcon
: SortNumericDownAltIcon;
} else {
SortIcon =
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
}
return (
<Fragment>
{sortedColumnName && (
<InputGroup>
{(sortDropdownItems.length > 0 && (
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle
id="awx-sort"
onToggle={this.handleDropdownToggle}
>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Sort`)}
onClick={this.handleSort}
>
<SortIcon />
</Button>
</InputGroup>
)}
</Fragment>
); );
} }
const sortedColumnName = defaultSortedColumn?.name;
const sortDropdownItems = columns
.filter(({ key }) => key !== sortKey)
.map(({ key, name }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
let SortIcon;
if (isNumeric) {
SortIcon =
sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon;
} else {
SortIcon =
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
}
return (
<Fragment>
{sortedColumnName && (
<InputGroup>
{(sortDropdownItems.length > 0 && (
<Dropdown
onToggle={handleDropdownToggle}
onSelect={handleDropdownSelect}
direction={up}
isOpen={isSortDropdownOpen}
toggle={
<DropdownToggle id="awx-sort" onToggle={handleDropdownToggle}>
{sortedColumnName}
</DropdownToggle>
}
dropdownItems={sortDropdownItems}
/>
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
<Button
variant={ButtonVariant.control}
aria-label={i18n._(t`Sort`)}
onClick={handleSort}
>
<SortIcon />
</Button>
</InputGroup>
)}
</Fragment>
);
} }
Sort.propTypes = { Sort.propTypes = {

View File

@@ -1,5 +1,10 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import Sort from './Sort'; import Sort from './Sort';
describe('<Sort />', () => { describe('<Sort />', () => {
@@ -105,7 +110,7 @@ describe('<Sort />', () => {
expect(onSort).toHaveBeenCalledWith('foo', 'ascending'); expect(onSort).toHaveBeenCalledWith('foo', 'ascending');
}); });
test('Changing dropdown correctly passes back new sort key', () => { test('Changing dropdown correctly passes back new sort key', async () => {
const qsConfig = { const qsConfig = {
namespace: 'item', namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' }, defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
@@ -131,44 +136,18 @@ describe('<Sort />', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} /> <Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort'); );
act(() => wrapper.find('Dropdown').invoke('onToggle')(true));
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } }); wrapper.update();
await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true);
wrapper
.find('li')
.at(0)
.prop('onClick')({ target: { innerText: 'Bar' } });
wrapper.update();
expect(onSort).toBeCalledWith('bar', 'ascending'); expect(onSort).toBeCalledWith('bar', 'ascending');
}); });
test('Opening dropdown correctly updates state', () => {
const qsConfig = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
integerFields: ['page', 'page_size'],
};
const columns = [
{
name: 'Foo',
key: 'foo',
},
{
name: 'Bar',
key: 'bar',
},
{
name: 'Bakery',
key: 'bakery',
},
];
const onSort = jest.fn();
const wrapper = mountWithContexts(
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
expect(wrapper.state('isSortDropdownOpen')).toEqual(false);
wrapper.instance().handleDropdownToggle(true);
expect(wrapper.state('isSortDropdownOpen')).toEqual(true);
});
test('It displays correct sort icon', () => { test('It displays correct sort icon', () => {
const forwardNumericIconSelector = 'SortNumericDownIcon'; const forwardNumericIconSelector = 'SortNumericDownIcon';
const reverseNumericIconSelector = 'SortNumericDownAltIcon'; const reverseNumericIconSelector = 'SortNumericDownAltIcon';

View File

@@ -1,5 +1,6 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import ActivityStream from './screens/ActivityStream';
import Applications from './screens/Application'; import Applications from './screens/Application';
import Credentials from './screens/Credential'; import Credentials from './screens/Credential';
import CredentialTypes from './screens/CredentialType'; import CredentialTypes from './screens/CredentialType';
@@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
path: '/schedules', path: '/schedules',
screen: Schedules, screen: Schedules,
}, },
{
title: i18n._(t`Activity Stream`),
path: '/activity_stream',
screen: ActivityStream,
},
{ {
title: i18n._(t`Workflow Approvals`), title: i18n._(t`Workflow Approvals`),
path: '/workflow_approvals', path: '/workflow_approvals',

View File

@@ -0,0 +1,269 @@
import React, { Fragment, useState, useEffect, useCallback } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Card,
PageSection,
PageSectionVariants,
SelectGroup,
Select,
SelectVariant,
SelectOption,
Title,
} from '@patternfly/react-core';
import DatalistToolbar from '../../components/DataListToolbar';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../components/PaginatedTable';
import useRequest from '../../util/useRequest';
import {
getQSConfig,
parseQueryString,
replaceParams,
encodeNonDefaultQueryString,
} from '../../util/qs';
import { ActivityStreamAPI } from '../../api';
import ActivityStreamListItem from './ActivityStreamListItem';
function ActivityStream({ i18n }) {
const { light } = PageSectionVariants;
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const location = useLocation();
const history = useHistory();
const urlParams = new URLSearchParams(location.search);
const activityStreamType = urlParams.get('type') || 'all';
let typeParams = {};
if (activityStreamType !== 'all') {
typeParams = {
or__object1__in: activityStreamType,
or__object2__in: activityStreamType,
};
}
const QS_CONFIG = getQSConfig(
'activity_stream',
{
page: 1,
page_size: 20,
order_by: '-timestamp',
},
['id', 'page', 'page_size']
);
const {
result: { results, count, relatedSearchableKeys, searchableKeys },
error: contentError,
isLoading,
request: fetchActivityStream,
} = useRequest(
useCallback(
async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
ActivityStreamAPI.read({ ...params, ...typeParams }),
ActivityStreamAPI.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] // eslint-disable-line react-hooks/exhaustive-deps
),
{
results: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchActivityStream();
}, [fetchActivityStream]);
const pushHistoryState = urlParamsToAdd => {
let searchParams = parseQueryString(QS_CONFIG, location.search);
searchParams = replaceParams(searchParams, { page: 1 });
const encodedParams = encodeNonDefaultQueryString(QS_CONFIG, searchParams, {
type: urlParamsToAdd.get('type'),
});
history.push(
encodedParams
? `${location.pathname}?${encodedParams}`
: location.pathname
);
};
return (
<Fragment>
<PageSection
variant={light}
className="pf-m-condensed"
style={{ display: 'flex', justifyContent: 'space-between' }}
>
<Title size="2xl" headingLevel="h2">
{i18n._(t`Activity Stream`)}
</Title>
<span id="grouped-type-select-id" hidden>
{i18n._(t`Activity Stream type selector`)}
</span>
<Select
width="250px"
maxHeight="480px"
variant={SelectVariant.single}
aria-labelledby="grouped-type-select-id"
className="activityTypeSelect"
onToggle={setIsTypeDropdownOpen}
onSelect={(event, selection) => {
if (selection) {
urlParams.set('type', selection);
}
setIsTypeDropdownOpen(false);
pushHistoryState(urlParams);
}}
selections={activityStreamType}
isOpen={isTypeDropdownOpen}
isGrouped
>
<SelectGroup label={i18n._(t`Views`)} key="views">
<SelectOption key="all_activity" value="all">
{i18n._(t`Dashboard (all activity)`)}
</SelectOption>
<SelectOption key="jobs" value="job">
{i18n._(t`Jobs`)}
</SelectOption>
<SelectOption key="schedules" value="schedule">
{i18n._(t`Schedules`)}
</SelectOption>
<SelectOption key="workflow_approvals" value="workflow_approval">
{i18n._(t`Workflow Approvals`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Resources`)} key="resources">
<SelectOption
key="templates"
value="job_template,workflow_job_template,workflow_job_template_node"
>
{i18n._(t`Templates`)}
</SelectOption>
<SelectOption key="credentials" value="credential">
{i18n._(t`Credentials`)}
</SelectOption>
<SelectOption key="projects" value="project">
{i18n._(t`Projects`)}
</SelectOption>
<SelectOption key="inventories" value="inventory">
{i18n._(t`Inventories`)}
</SelectOption>
<SelectOption key="hosts" value="host">
{i18n._(t`Hosts`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Access`)} key="access">
<SelectOption key="organizations" value="organization">
{i18n._(t`Organizations`)}
</SelectOption>
<SelectOption key="users" value="user">
{i18n._(t`Users`)}
</SelectOption>
<SelectOption key="teams" value="team">
{i18n._(t`Teams`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Adminisration`)} key="administration">
<SelectOption key="credential_types" value="credential_type">
{i18n._(t`Credential Types`)}
</SelectOption>
<SelectOption
key="notification_templates"
value="notification_template"
>
{i18n._(t`Notification Templates`)}
</SelectOption>
<SelectOption key="instance_groups" value="instance_group">
{i18n._(t`Instance Groups`)}
</SelectOption>
<SelectOption
key="applications"
value="o_auth2_application,o_auth2_access_token"
>
{i18n._(t`Applications & Tokens`)}
</SelectOption>
</SelectGroup>
<SelectGroup label={i18n._(t`Settings`)} key="settings">
<SelectOption key="settings" value="setting">
{i18n._(t`Settings`)}
</SelectOption>
</SelectGroup>
</Select>
</PageSection>
<PageSection>
<Card>
<PaginatedTable
contentError={contentError}
hasContentLoading={isLoading}
items={results}
itemCount={count}
pluralizedItemName={i18n._(t`Events`)}
qsConfig={QS_CONFIG}
toolbarSearchColumns={[
{
name: i18n._(t`Keyword`),
key: 'search',
isDefault: true,
},
{
name: i18n._(t`Initiated by (username)`),
key: 'actor__username__icontains',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Time`),
key: 'timestamp',
},
{
name: i18n._(t`Initiated by`),
key: 'actor__username',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="timestamp">{i18n._(t`Time`)}</HeaderCell>
<HeaderCell sortKey="actor__username">
{i18n._(t`Initiated by`)}
</HeaderCell>
<HeaderCell>{i18n._(t`Event`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderToolbar={props => (
<DatalistToolbar {...props} qsConfig={QS_CONFIG} />
)}
renderRow={streamItem => (
<ActivityStreamListItem streamItem={streamItem} />
)}
/>
</Card>
</PageSection>
</Fragment>
);
}
export default withI18n()(ActivityStream);

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStream from './ActivityStream';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ActivityStream />', () => {
let pageWrapper;
beforeEach(() => {
pageWrapper = mountWithContexts(<ActivityStream />);
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
});
});

View File

@@ -0,0 +1,584 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
const buildAnchor = (obj, resource, activity) => {
let url;
let name;
// try/except pattern asserts that:
// if we encounter a case where a UI url can't or
// shouldn't be generated, just supply the name of the resource
try {
// catch-all case to avoid generating urls if a resource has been deleted
// if a resource still exists, it'll be serialized in the activity's summary_fields
if (!activity.summary_fields[resource]) {
throw new Error('The referenced resource no longer exists');
}
switch (resource) {
case 'custom_inventory_script':
url = `/inventory_scripts/${obj.id}/`;
break;
case 'group':
if (
activity.operation === 'create' ||
activity.operation === 'delete'
) {
// the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey'
const [inventory_id] = activity.changes.inventory
.split('-')
.slice(-1);
url = `/inventories/inventory/${inventory_id}/groups/${activity.changes.id}/details/`;
} else {
url = `/inventories/inventory/${
activity.summary_fields.inventory[0].id
}/groups/${activity.changes.id ||
activity.changes.object1_pk}/details/`;
}
break;
case 'host':
url = `/hosts/${obj.id}/`;
break;
case 'job':
url = `/jobs/${obj.id}/`;
break;
case 'inventory':
url =
obj?.kind === 'smart'
? `/inventories/smart_inventory/${obj.id}/`
: `/inventories/inventory/${obj.id}/`;
break;
case 'schedule':
// schedule urls depend on the resource they're associated with
if (activity.summary_fields.job_template) {
const jt_id = activity.summary_fields.job_template[0].id;
url = `/templates/job_template/${jt_id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.workflow_job_template) {
const wfjt_id = activity.summary_fields.workflow_job_template[0].id;
url = `/templates/workflow_job_template/${wfjt_id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.project) {
url = `/projects/${activity.summary_fields.project[0].id}/schedules/${obj.id}/`;
} else if (activity.summary_fields.system_job_template) {
url = null;
} else {
// urls for inventory sync schedules currently depend on having
// an inventory id and group id
throw new Error(
'activity.summary_fields to build this url not implemented yet'
);
}
break;
case 'setting':
url = `/settings/`;
break;
case 'notification_template':
url = `/notification_templates/${obj.id}/`;
break;
case 'role':
throw new Error(
'role object management is not consolidated to a single UI view'
);
case 'job_template':
url = `/templates/job_template/${obj.id}/`;
break;
case 'workflow_job_template':
url = `/templates/workflow_job_template/${obj.id}/`;
break;
case 'workflow_job_template_node': {
const {
id: wfjt_id,
name: wfjt_name,
} = activity.summary_fields.workflow_job_template[0];
url = `/templates/workflow_job_template/${wfjt_id}/`;
name = wfjt_name;
break;
}
case 'workflow_job':
url = `/workflows/${obj.id}/`;
break;
case 'label':
url = null;
break;
case 'inventory_source': {
const inventoryId = (obj.inventory || '').split('-').reverse()[0];
url = `/inventories/inventory/${inventoryId}/sources/${obj.id}/details/`;
break;
}
case 'o_auth2_application':
url = `/applications/${obj.id}/`;
break;
case 'workflow_approval':
url = `/jobs/workflow/${activity.summary_fields.workflow_job[0].id}/output/`;
name = `${activity.summary_fields.workflow_job[0].name} | ${activity.summary_fields.workflow_approval[0].name}`;
break;
case 'workflow_approval_template':
url = `/templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/visualizer/`;
name = `${activity.summary_fields.workflow_job_template[0].name} | ${activity.summary_fields.workflow_approval_template[0].name}`;
break;
default:
url = `/${resource}s/${obj.id}/`;
}
name = name || obj.name || obj.username;
if (url) {
return <Link to={url}>{name}</Link>;
}
return <span>{name}</span>;
} catch (err) {
return <span>{obj.name || obj.username || ''}</span>;
}
};
const getPastTense = item => {
return /e$/.test(item) ? `${item}d` : `${item}ed`;
};
const isGroupRelationship = item => {
return (
item.object1 === 'group' &&
item.object2 === 'group' &&
item.summary_fields.group.length > 1
);
};
const buildLabeledLink = (label, link) => {
return (
<span>
{label} {link}
</span>
);
};
function ActivityStreamDescription({ i18n, activity }) {
const labeledLinks = [];
// Activity stream objects will outlive the resources they reference
// in that case, summary_fields will not be available - show generic error text instead
try {
switch (activity.object_association) {
// explicit role dis+associations
case 'role': {
let { object1, object2 } = activity;
// if object1 winds up being the role's resource, we need to swap the objects
// in order to make the sentence make sense.
if (activity.object_type === object1) {
object1 = activity.object2;
object2 = activity.object1;
}
// object1 field is resource targeted by the dis+association
// object2 field is the resource the role is inherited from
// summary_field.role[0] contains ref info about the role
switch (activity.operation) {
// expected outcome: "disassociated <object2> role_name from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields.group[1],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields.group[0],
object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[object2][0],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields[object1][0],
object1,
activity
)
)
);
}
break;
// expected outcome: "associated <object2> role_name to <object1>"
case 'associate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields.group[1],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields.group[0],
object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[object2][0],
object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields[object1][0],
object1,
activity
)
)
);
}
break;
default:
break;
}
break;
// inherited role dis+associations (logic identical to case 'role')
}
case 'parents':
// object1 field is resource targeted by the dis+association
// object2 field is the resource the role is inherited from
// summary_field.role[0] contains ref info about the role
switch (activity.operation) {
// expected outcome: "disassociated <object2> role_name from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} from`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
// expected outcome: "associated <object2> role_name to <object1>"
case 'associate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
getPastTense(activity.operation),
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`${activity.summary_fields.role[0].role_field} to`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
default:
break;
}
break;
// CRUD operations / resource on resource dis+associations
default:
switch (activity.operation) {
// expected outcome: "disassociated <object2> from <object1>"
case 'disassociate':
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
} else if (
activity.object1 === 'workflow_job_template_node' &&
activity.object2 === 'workflow_job_template_node'
) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} two nodes on workflow`,
buildAnchor(
activity.summary_fields[activity.object1[0]],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object2}`,
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`from ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
// expected outcome "associated <object2> to <object1>"
case 'associate':
// groups are the only resource that can be associated/disassociated into each other
if (isGroupRelationship(activity)) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields.group[0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields.group[1],
activity.object2,
activity
)
)
);
} else if (
activity.object1 === 'workflow_job_template_node' &&
activity.object2 === 'workflow_job_template_node'
) {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} two nodes on workflow`,
buildAnchor(
activity.summary_fields[activity.object1[0]],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
labeledLinks.push(
buildLabeledLink(
`to ${activity.object2}`,
buildAnchor(
activity.summary_fields[activity.object2][0],
activity.object2,
activity
)
)
);
}
break;
case 'delete':
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(activity.changes, activity.object1, activity)
)
);
break;
// expected outcome: "operation <object1>"
case 'update':
if (
activity.object1 === 'workflow_approval' &&
activity?.changes?.status?.length === 2
) {
let operationText = '';
if (activity.changes.status[1] === 'successful') {
operationText = i18n._(t`approved`);
} else if (activity.changes.status[1] === 'failed') {
if (
activity.changes.timed_out &&
activity.changes.timed_out[1] === true
) {
operationText = i18n._(t`timed out`);
} else {
operationText = i18n._(t`denied`);
}
} else {
operationText = i18n._(t`updated`);
}
labeledLinks.push(
buildLabeledLink(
`${operationText} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
} else {
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(
activity.summary_fields[activity.object1][0],
activity.object1,
activity
)
)
);
}
break;
case 'create':
labeledLinks.push(
buildLabeledLink(
`${getPastTense(activity.operation)} ${activity.object1}`,
buildAnchor(activity.changes, activity.object1, activity)
)
);
break;
default:
break;
}
break;
}
} catch (err) {
return <span>{i18n._(t`Event summary not available`)}</span>;
}
return (
<span>
{labeledLinks.reduce(
(acc, x) =>
acc === null ? (
x
) : (
<>
{acc} {x}
</>
),
null
)}
</span>
);
}
export default withI18n()(ActivityStreamDescription);

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamDescription from './ActivityStreamDescription';
describe('ActivityStreamDescription', () => {
test('initially renders succesfully', () => {
const description = mountWithContexts(
<ActivityStreamDescription activity={{}} />
);
expect(description.find('span').length).toBe(1);
});
});

View File

@@ -0,0 +1,66 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button, Modal } from '@patternfly/react-core';
import { SearchPlusIcon } from '@patternfly/react-icons';
import { formatDateString } from '../../util/dates';
import { DetailList, Detail } from '../../components/DetailList';
import { VariablesDetail } from '../../components/CodeMirrorInput';
function ActivityStreamDetailButton({ i18n, streamItem, user, description }) {
const [isOpen, setIsOpen] = useState(false);
const setting = streamItem?.summary_fields?.setting;
const changeRows = Math.max(
Object.keys(streamItem?.changes || []).length + 2,
6
);
return (
<>
<Button
aria-label={i18n._(t`View event details`)}
variant="plain"
component="button"
onClick={() => setIsOpen(true)}
>
<SearchPlusIcon />
</Button>
<Modal
variant="large"
isOpen={isOpen}
title={i18n._(t`Event detail`)}
aria-label={i18n._(t`Event detail modal`)}
onClose={() => setIsOpen(false)}
>
<DetailList gutter="sm">
<Detail
label={i18n._(t`Time`)}
value={formatDateString(streamItem.timestamp)}
/>
<Detail label={i18n._(t`Initiated by`)} value={user} />
<Detail
label={i18n._(t`Setting category`)}
value={setting && setting[0]?.category}
/>
<Detail
label={i18n._(t`Setting name`)}
value={setting && setting[0]?.name}
/>
<Detail fullWidth label={i18n._(t`Action`)} value={description} />
{streamItem?.changes && (
<VariablesDetail
label={i18n._(t`Changes`)}
rows={changeRows}
value={streamItem?.changes}
/>
)}
</DetailList>
</Modal>
</>
);
}
export default withI18n()(ActivityStreamDetailButton);

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamDetailButton from './ActivityStreamDetailButton';
jest.mock('../../api/models/ActivityStream');
describe('<ActivityStreamDetailButton />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<ActivityStreamDetailButton
streamItem={{
timestamp: '12:00:00',
}}
user={<Link to="/users/1/details">Bob</Link>}
description={<span>foo</span>}
/>
);
});
});

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { shape } from 'prop-types';
import { withI18n } from '@lingui/react';
import { Tr, Td } from '@patternfly/react-table';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import { formatDateString } from '../../util/dates';
import { ActionsTd, ActionItem } from '../../components/PaginatedTable';
import ActivityStreamDetailButton from './ActivityStreamDetailButton';
import ActivityStreamDescription from './ActivityStreamDescription';
function ActivityStreamListItem({ streamItem, i18n }) {
ActivityStreamListItem.propTypes = {
streamItem: shape({}).isRequired,
};
const buildUser = item => {
let link;
if (item?.summary_fields?.actor?.id) {
link = (
<Link to={`/users/${item.summary_fields.actor.id}/details`}>
{item.summary_fields.actor.username}
</Link>
);
} else if (item?.summary_fields?.actor) {
link = i18n._(t`${item.summary_fields.actor.username} (deleted)`);
} else {
link = i18n._(t`system`);
}
return link;
};
const labelId = `check-action-${streamItem.id}`;
const user = buildUser(streamItem);
const description = <ActivityStreamDescription activity={streamItem} />;
return (
<Tr id={streamItem.id} aria-labelledby={labelId}>
<Td />
<Td dataLabel={i18n._(t`Time`)}>
{streamItem.timestamp ? formatDateString(streamItem.timestamp) : ''}
</Td>
<Td dataLabel={i18n._(t`Initiated By`)}>{user}</Td>
<Td id={labelId} dataLabel={i18n._(t`Event`)}>
{description}
</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem visible tooltip={i18n._(t`View event details`)}>
<ActivityStreamDetailButton
streamItem={streamItem}
user={user}
description={description}
/>
</ActionItem>
</ActionsTd>
</Tr>
);
}
export default withI18n()(ActivityStreamListItem);

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ActivityStreamListItem from './ActivityStreamListItem';
jest.mock('../../api/models/ActivityStream');
describe('<ActivityStreamListItem />', () => {
test('initially renders succesfully', () => {
mountWithContexts(
<table>
<tbody>
<ActivityStreamListItem
streamItem={{
timestamp: '12:00:00',
}}
onSelect={() => {}}
/>
</tbody>
</table>
);
});
});

View File

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

View File

@@ -12,7 +12,7 @@ import {
import ApplicationsList from './ApplicationsList'; import ApplicationsList from './ApplicationsList';
import ApplicationAdd from './ApplicationAdd'; import ApplicationAdd from './ApplicationAdd';
import Application from './Application'; import Application from './Application';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import { Detail, DetailList } from '../../components/DetailList'; import { Detail, DetailList } from '../../components/DetailList';
const ApplicationAlert = styled(Alert)` const ApplicationAlert = styled(Alert)`
@@ -45,7 +45,10 @@ function Applications({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="o_auth2_application,o_auth2_access_token"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/applications/add"> <Route path="/applications/add">
<ApplicationAdd <ApplicationAdd

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Applications from './Applications'; import Applications from './Applications';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Applications />', () => { describe('<Applications />', () => {
let wrapper; let wrapper;

View File

@@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import Credential from './Credential'; import Credential from './Credential';
import CredentialAdd from './CredentialAdd'; import CredentialAdd from './CredentialAdd';
import { CredentialList } from './CredentialList'; import { CredentialList } from './CredentialList';
@@ -34,7 +34,10 @@ function Credentials({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="credential"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/credentials/add"> <Route path="/credentials/add">
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config> <Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>

View File

@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Credentials from './Credentials'; import Credentials from './Credentials';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Credentials />', () => { describe('<Credentials />', () => {
let wrapper; let wrapper;
@@ -30,8 +34,8 @@ describe('<Credentials />', () => {
}, },
}); });
expect(wrapper.find('Crumb').length).toBe(1); expect(wrapper.find('Crumb').length).toBe(0);
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials'); expect(wrapper.find('Title').text()).toBe('Credentials');
}); });
test('should display create new credential breadcrumb heading', () => { test('should display create new credential breadcrumb heading', () => {
@@ -51,8 +55,6 @@ describe('<Credentials />', () => {
}); });
expect(wrapper.find('Crumb').length).toBe(2); expect(wrapper.find('Crumb').length).toBe(2);
expect(wrapper.find('BreadcrumbHeading').text()).toBe( expect(wrapper.find('Title').text()).toBe('Create New Credential');
'Create New Credential'
);
}); });
}); });

View File

@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom';
import CredentialTypeAdd from './CredentialTypeAdd'; import CredentialTypeAdd from './CredentialTypeAdd';
import CredentialTypeList from './CredentialTypeList'; import CredentialTypeList from './CredentialTypeList';
import CredentialType from './CredentialType'; import CredentialType from './CredentialType';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
function CredentialTypes({ i18n }) { function CredentialTypes({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) {
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="credential_type"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/credential_types/add"> <Route path="/credential_types/add">
<CredentialTypeAdd /> <CredentialTypeAdd />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CredentialTypes from './CredentialTypes'; import CredentialTypes from './CredentialTypes';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<CredentialTypes/>', () => { describe('<CredentialTypes/>', () => {
let pageWrapper; let pageWrapper;
let pageSections; let pageSections;

View File

@@ -18,7 +18,7 @@ import {
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { DashboardAPI } from '../../api'; import { DashboardAPI } from '../../api';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import JobList from '../../components/JobList'; import JobList from '../../components/JobList';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart'; import LineChart from './shared/LineChart';
@@ -117,7 +117,10 @@ function Dashboard({ i18n }) {
} }
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} /> <ScreenHeader
streamType="all"
breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }}
/>
<PageSection> <PageSection>
<Counts> <Counts>
<Count <Count

View File

@@ -7,6 +7,9 @@ import { DashboardAPI } from '../../api';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Dashboard />', () => { describe('<Dashboard />', () => {
let pageWrapper; let pageWrapper;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
@@ -13,9 +13,14 @@ import PaginatedDataList, {
ToolbarDeleteButton, ToolbarDeleteButton,
} from '../../../components/PaginatedDataList'; } from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest'; import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import {
encodeQueryString,
getQSConfig,
parseQueryString,
} from '../../../util/qs';
import HostListItem from './HostListItem'; import HostListItem from './HostListItem';
import SmartInventoryButton from './SmartInventoryButton';
const QS_CONFIG = getQSConfig('host', { const QS_CONFIG = getQSConfig('host', {
page: 1, page: 1,
@@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
}); });
function HostList({ i18n }) { function HostList({ i18n }) {
const history = useHistory();
const location = useLocation(); const location = useLocation();
const match = useRouteMatch(); const match = useRouteMatch();
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
const nonDefaultSearchParams = {};
Object.keys(parsedQueryStrings).forEach(key => {
if (!QS_CONFIG.defaultParams[key]) {
nonDefaultSearchParams[key] = parsedQueryStrings[key];
}
});
const hasNonDefaultSearchParams =
Object.keys(nonDefaultSearchParams).length > 0;
const { const {
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys }, result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
@@ -99,6 +116,14 @@ function HostList({ i18n }) {
} }
}; };
const handleSmartInventoryClick = () => {
history.push(
`/inventories/smart_inventory/add?host_filter=${encodeURIComponent(
encodeQueryString(nonDefaultSearchParams)
)}`
);
};
const canAdd = const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@@ -157,6 +182,14 @@ function HostList({ i18n }) {
itemsToDelete={selected} itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)} pluralizedItemName={i18n._(t`Hosts`)}
/>, />,
...(canAdd
? [
<SmartInventoryButton
isDisabled={!hasNonDefaultSearchParams}
onClick={() => handleSmartInventoryClick()}
/>,
]
: []),
]} ]}
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '../../../api'; import { HostsAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
@@ -257,7 +258,7 @@ describe('<HostList />', () => {
expect(modal.prop('title')).toEqual('Error!'); expect(modal.prop('title')).toEqual('Error!');
}); });
test('should show Add button according to permissions', async () => { test('should show Add and Smart Inventory buttons according to permissions', async () => {
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<HostList />); wrapper = mountWithContexts(<HostList />);
@@ -265,9 +266,10 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper); await waitForLoaded(wrapper);
expect(wrapper.find('ToolbarAddButton').length).toBe(1); expect(wrapper.find('ToolbarAddButton').length).toBe(1);
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1);
}); });
test('should hide Add button according to permissions', async () => { test('should hide Add and Smart Inventory buttons according to permissions', async () => {
HostsAPI.readOptions.mockResolvedValue({ HostsAPI.readOptions.mockResolvedValue({
data: { data: {
actions: { actions: {
@@ -282,5 +284,44 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper); await waitForLoaded(wrapper);
expect(wrapper.find('ToolbarAddButton').length).toBe(0); expect(wrapper.find('ToolbarAddButton').length).toBe(0);
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0);
});
test('Smart Inventory button should be disabled when no search params are present', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<HostList />);
});
await waitForLoaded(wrapper);
expect(
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
).toBe(true);
});
test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: ['/hosts?host.name__icontains=foo'],
});
await act(async () => {
wrapper = mountWithContexts(<HostList />, {
context: { router: { history } },
});
});
await waitForLoaded(wrapper);
expect(
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
).toBe(false);
await act(async () => {
wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click');
});
wrapper.update();
expect(history.location.pathname).toEqual(
'/inventories/smart_inventory/add'
);
expect(history.location.search).toEqual(
'?host_filter=name__icontains%3Dfoo'
);
}); });
}); });

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { func } from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../../contexts/Kebabified';
function SmartInventoryButton({ onClick, i18n, isDisabled }) {
const { isKebabified } = useKebabifiedMenu();
if (isKebabified) {
return (
<DropdownItem
key="add"
isDisabled={isDisabled}
component="button"
onClick={onClick}
>
{i18n._(t`Smart Inventory`)}
</DropdownItem>
);
}
return (
<Tooltip
key="smartInventory"
content={
!isDisabled
? i18n._(t`Create a new Smart Inventory with the applied filter`)
: i18n._(
t`Enter at least one search filter to create a new Smart Inventory`
)
}
position="top"
>
<div>
<Button
onClick={onClick}
aria-label={i18n._(t`Smart Inventory`)}
variant="secondary"
isDisabled={isDisabled}
>
{i18n._(t`Smart Inventory`)}
</Button>
</div>
</Tooltip>
);
}
SmartInventoryButton.propTypes = {
onClick: func.isRequired,
};
export default withI18n()(SmartInventoryButton);

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import SmartInventoryButton from './SmartInventoryButton';
describe('<SmartInventoryButton />', () => {
test('should render button', () => {
const onClick = jest.fn();
const wrapper = mountWithContexts(
<SmartInventoryButton onClick={onClick} />
);
const button = wrapper.find('button');
expect(button).toHaveLength(1);
button.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import HostList from './HostList'; import HostList from './HostList';
import HostAdd from './HostAdd'; import HostAdd from './HostAdd';
@@ -37,7 +37,7 @@ function Hosts({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="host" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/hosts/add"> <Route path="/hosts/add">
<HostAdd /> <HostAdd />

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Hosts from './Hosts'; import Hosts from './Hosts';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Hosts />', () => { describe('<Hosts />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Hosts />); mountWithContexts(<Hosts />);
@@ -27,7 +31,7 @@ describe('<Hosts />', () => {
}, },
}, },
}); });
expect(wrapper.find('BreadcrumbHeading').length).toBe(1); expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });

View File

@@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup';
import ContainerGroupAdd from './ContainerGroupAdd'; import ContainerGroupAdd from './ContainerGroupAdd';
import ContainerGroup from './ContainerGroup'; import ContainerGroup from './ContainerGroup';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
function InstanceGroups({ i18n }) { function InstanceGroups({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const [breadcrumbConfig, setBreadcrumbConfig] = useState({
@@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) {
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="instance_group"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/instance_groups/container_group/add"> <Route path="/instance_groups/container_group/add">
<ContainerGroupAdd /> <ContainerGroupAdd />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import InstanceGroups from './InstanceGroups'; import InstanceGroups from './InstanceGroups';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<InstanceGroups/>', () => { describe('<InstanceGroups/>', () => {
let pageWrapper; let pageWrapper;
let pageSections; let pageSections;

View File

@@ -1,10 +1,10 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import { InventoryList } from './InventoryList'; import { InventoryList } from './InventoryList';
import Inventory from './Inventory'; import Inventory from './Inventory';
import SmartInventory from './SmartInventory'; import SmartInventory from './SmartInventory';
@@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd';
import SmartInventoryAdd from './SmartInventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd';
function Inventories({ i18n }) { function Inventories({ i18n }) {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({ const initScreenHeader = useRef({
'/inventories': i18n._(t`Inventories`), '/inventories': i18n._(t`Inventories`),
'/inventories/inventory/add': i18n._(t`Create new inventory`), '/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`), '/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
}); });
const buildBreadcrumbConfig = useCallback( const [breadcrumbConfig, setScreenHeader] = useState(
(inventory, nested, schedule) => { initScreenHeader.current
);
const [inventory, setInventory] = useState();
const [nestedObject, setNestedGroup] = useState();
const [schedule, setSchedule] = useState();
const setBreadcrumbConfig = useCallback(
(passedInventory, passedNestedObject, passedSchedule) => {
if (passedInventory && passedInventory.name !== inventory?.name) {
setInventory(passedInventory);
}
if (
passedNestedObject &&
passedNestedObject.name !== nestedObject?.name
) {
setNestedGroup(passedNestedObject);
}
if (passedSchedule && passedSchedule.name !== schedule?.name) {
setSchedule(passedSchedule);
}
if (!inventory) { if (!inventory) {
return; return;
} }
@@ -32,13 +52,8 @@ function Inventories({ i18n }) {
const inventoryGroupsPath = `${inventoryPath}/groups`; const inventoryGroupsPath = `${inventoryPath}/groups`;
const inventorySourcesPath = `${inventoryPath}/sources`; const inventorySourcesPath = `${inventoryPath}/sources`;
setBreadcrumbConfig({ setScreenHeader({
'/inventories': i18n._(t`Inventories`), ...initScreenHeader.current,
'/inventories/inventory/add': i18n._(t`Create new inventory`),
'/inventories/smart_inventory/add': i18n._(
t`Create new smart inventory`
),
[inventoryPath]: `${inventory.name}`, [inventoryPath]: `${inventory.name}`,
[`${inventoryPath}/access`]: i18n._(t`Access`), [`${inventoryPath}/access`]: i18n._(t`Access`),
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`), [`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
@@ -47,55 +62,74 @@ function Inventories({ i18n }) {
[inventoryHostsPath]: i18n._(t`Hosts`), [inventoryHostsPath]: i18n._(t`Hosts`),
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`), [`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`, [`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._(
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._( t`Edit details`
),
[`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._(
t`Host details` t`Host details`
), ),
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._( [`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._(
t`Completed jobs` t`Completed jobs`
), ),
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`), [`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`),
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`), [`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`),
[inventoryGroupsPath]: i18n._(t`Groups`), [inventoryGroupsPath]: i18n._(t`Groups`),
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`), [`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`, [`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), [`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._(
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._( t`Edit details`
),
[`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._(
t`Group details` t`Group details`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`), [`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._(
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._( t`Hosts`
),
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._(
t`Create new host` t`Create new host`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._( [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._(
t`Groups` t`Related Groups`
), ),
[`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._( [`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._(
t`Create new group` t`Create new group`
), ),
[`${inventorySourcesPath}`]: i18n._(t`Sources`), [`${inventorySourcesPath}`]: i18n._(t`Sources`),
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`), [`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`, [`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`), [`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._(
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`), t`Details`
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._( ),
[`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._(
t`Edit details`
),
[`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._(
t`Schedules` t`Schedules`
), ),
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`, [`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._( [`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._(
t`Create New Schedule`
),
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._(
t`Schedule details` t`Schedule details`
), ),
[`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
t`Notifcations`
),
}); });
}, },
[i18n] [i18n, inventory, nestedObject, schedule]
); );
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="inventory"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path="/inventories/inventory/add"> <Route path="/inventories/inventory/add">
<InventoryAdd /> <InventoryAdd />
@@ -106,12 +140,12 @@ function Inventories({ i18n }) {
<Route path="/inventories/inventory/:id"> <Route path="/inventories/inventory/:id">
<Config> <Config>
{({ me }) => ( {({ me }) => (
<Inventory setBreadcrumb={buildBreadcrumbConfig} me={me || {}} /> <Inventory setBreadcrumb={setBreadcrumbConfig} me={me || {}} />
)} )}
</Config> </Config>
</Route> </Route>
<Route path="/inventories/smart_inventory/:id"> <Route path="/inventories/smart_inventory/:id">
<SmartInventory setBreadcrumb={buildBreadcrumbConfig} /> <SmartInventory setBreadcrumb={setBreadcrumbConfig} />
</Route> </Route>
<Route path="/inventories"> <Route path="/inventories">
<InventoryList /> <InventoryList />

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Inventories from './Inventories'; import Inventories from './Inventories';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Inventories />', () => { describe('<Inventories />', () => {
let pageWrapper; let pageWrapper;

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
import { Formik, useField, useFormikContext } from 'formik'; import { Formik, useField, useFormikContext } from 'formik';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useLocation } from 'react-router-dom';
import { func, shape, arrayOf } from 'prop-types'; import { func, shape, arrayOf } from 'prop-types';
import { Form } from '@patternfly/react-core'; import { Form } from '@patternfly/react-core';
import { InstanceGroup } from '../../../types'; import { InstanceGroup } from '../../../types';
@@ -14,6 +15,10 @@ import {
FormColumnLayout, FormColumnLayout,
FormFullWidthLayout, FormFullWidthLayout,
} from '../../../components/FormLayout'; } from '../../../components/FormLayout';
import {
toHostFilter,
toSearchParams,
} from '../../../components/Lookup/shared/HostFilterUtils';
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
@@ -109,9 +114,17 @@ function SmartInventoryForm({
onCancel, onCancel,
submitError, submitError,
}) { }) {
const { search } = useLocation();
const queryParams = new URLSearchParams(search);
const hostFilterFromParams = queryParams.get('host_filter');
const initialValues = { const initialValues = {
description: inventory.description || '', description: inventory.description || '',
host_filter: inventory.host_filter || '', host_filter:
inventory.host_filter ||
(hostFilterFromParams
? toHostFilter(toSearchParams(hostFilterFromParams))
: ''),
instance_groups: instanceGroups || [], instance_groups: instanceGroups || [],
kind: 'smart', kind: 'smart',
name: inventory.name || '', name: inventory.name || '',

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
@@ -135,6 +136,29 @@ describe('<SmartInventoryForm />', () => {
}); });
}); });
test('should pre-fill the host filter when query param present and not editing', async () => {
let wrapper;
const history = createMemoryHistory({
initialEntries: [
'/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo',
],
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} />,
{
context: { router: { history } },
}
);
});
wrapper.update();
const nameChipGroup = wrapper.find(
'HostFilterLookup ChipGroup[categoryName="Name"]'
);
expect(nameChipGroup.find('Chip').length).toBe(1);
wrapper.unmount();
});
test('should throw content error when option request fails', async () => { test('should throw content error when option request fails', async () => {
let wrapper; let wrapper;
InventoriesAPI.readOptions.mockImplementationOnce(() => InventoriesAPI.readOptions.mockImplementationOnce(() =>

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Job from './Jobs'; import Job from './Jobs';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Job />', () => { describe('<Job />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Job />); mountWithContexts(<Job />);

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { I18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@@ -518,7 +518,7 @@ class JobOutput extends Component {
} }
render() { render() {
const { job, i18n } = this.props; const { job } = this.props;
const { const {
contentError, contentError,
@@ -596,15 +596,21 @@ class JobOutput extends Component {
</OutputWrapper> </OutputWrapper>
</CardBody> </CardBody>
{deletionError && ( {deletionError && (
<AlertModal <>
isOpen={deletionError} <I18n>
variant="danger" {({ i18n }) => (
onClose={() => this.setState({ deletionError: null })} <AlertModal
title={i18n._(t`Job Delete Error`)} isOpen={deletionError}
label={i18n._(t`Job Delete Error`)} variant="danger"
> onClose={() => this.setState({ deletionError: null })}
<ErrorDetail error={deletionError} /> title={i18n._(t`Job Delete Error`)}
</AlertModal> label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</I18n>
</>
)} )}
</Fragment> </Fragment>
); );
@@ -612,4 +618,4 @@ class JobOutput extends Component {
} }
export { JobOutput as _JobOutput }; export { JobOutput as _JobOutput };
export default withI18n()(withRouter(JobOutput)); export default withRouter(JobOutput);

View File

@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { UnifiedJobsAPI } from '../../api'; import { UnifiedJobsAPI } from '../../api';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const NOT_FOUND = 'not found'; const NOT_FOUND = 'not found';
@@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
); );
} }
if (isLoading || !job?.id) { if (isLoading || !job?.id) {
// TODO show loading state return (
return <div>Loading...</div>; <PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
} }
const type = JOB_TYPE_URL_SEGMENTS[job.type]; const type = JOB_TYPE_URL_SEGMENTS[job.type];
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />; return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;

View File

@@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection } from '@patternfly/react-core'; import { PageSection } from '@patternfly/react-core';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import Job from './Job'; import Job from './Job';
import JobTypeRedirect from './JobTypeRedirect'; import JobTypeRedirect from './JobTypeRedirect';
import JobList from '../../components/JobList'; import JobList from '../../components/JobList';
@@ -40,7 +40,7 @@ function Jobs({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="job" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route exact path={match.path}> <Route exact path={match.path}>
<PageSection> <PageSection>

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Jobs from './Jobs'; import Jobs from './Jobs';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Jobs />', () => { describe('<Jobs />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Jobs />); mountWithContexts(<Jobs />);
@@ -27,7 +31,7 @@ describe('<Jobs />', () => {
}, },
}, },
}); });
expect(wrapper.find('BreadcrumbHeading').length).toBe(1); expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });
}); });

View File

@@ -2,12 +2,13 @@ import React, { Fragment } from 'react';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
function ManagementJobs({ i18n }) { function ManagementJobs({ i18n }) {
return ( return (
<Fragment> <Fragment>
<Breadcrumbs <ScreenHeader
streamType="none"
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }} breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
/> />
</Fragment> </Fragment>

View File

@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ManagementJobs from './ManagementJobs'; import ManagementJobs from './ManagementJobs';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ManagementJobs />', () => { describe('<ManagementJobs />', () => {
let pageWrapper; let pageWrapper;
@@ -17,6 +21,6 @@ describe('<ManagementJobs />', () => {
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1); expect(pageWrapper.length).toBe(1);
expect(pageWrapper.find('Breadcrumbs').length).toBe(1); expect(pageWrapper.find('ScreenHeader').length).toBe(1);
}); });
}); });

View File

@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import NotificationTemplateList from './NotificationTemplateList'; import NotificationTemplateList from './NotificationTemplateList';
import NotificationTemplateAdd from './NotificationTemplateAdd'; import NotificationTemplateAdd from './NotificationTemplateAdd';
import NotificationTemplate from './NotificationTemplate'; import NotificationTemplate from './NotificationTemplate';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
function NotificationTemplates({ i18n }) { function NotificationTemplates({ i18n }) {
const match = useRouteMatch(); const match = useRouteMatch();
@@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="notification_template"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path={`${match.url}/add`}> <Route path={`${match.url}/add`}>
<NotificationTemplateAdd /> <NotificationTemplateAdd />

View File

@@ -2,6 +2,10 @@ import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import NotificationTemplates from './NotificationTemplates'; import NotificationTemplates from './NotificationTemplates';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<NotificationTemplates />', () => { describe('<NotificationTemplates />', () => {
let pageWrapper; let pageWrapper;
let pageSections; let pageSections;

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import OrganizationsList from './OrganizationList/OrganizationList'; import OrganizationsList from './OrganizationList/OrganizationList';
import OrganizationAdd from './OrganizationAdd/OrganizationAdd'; import OrganizationAdd from './OrganizationAdd/OrganizationAdd';
@@ -42,7 +42,10 @@ function Organizations({ i18n }) {
return ( return (
<Fragment> <Fragment>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader
streamType="organization"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch> <Switch>
<Route path={`${match.path}/add`}> <Route path={`${match.path}/add`}>
<OrganizationAdd /> <OrganizationAdd />

View File

@@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Organizations from './Organizations'; import Organizations from './Organizations';
jest.mock('../../api'); jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Organizations />', () => { describe('<Organizations />', () => {
test('initially renders succesfully', async () => { test('initially renders succesfully', async () => {

View File

@@ -3,7 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import ProjectsList from './ProjectList/ProjectList'; import ProjectsList from './ProjectList/ProjectList';
import ProjectAdd from './ProjectAdd/ProjectAdd'; import ProjectAdd from './ProjectAdd/ProjectAdd';
@@ -45,7 +45,7 @@ function Projects({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="project" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/projects/add"> <Route path="/projects/add">
<ProjectAdd /> <ProjectAdd />

View File

@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Projects from './Projects'; import Projects from './Projects';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Projects />', () => { describe('<Projects />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Projects />); mountWithContexts(<Projects />);
@@ -27,7 +31,7 @@ describe('<Projects />', () => {
}, },
}, },
}); });
expect(wrapper.find('BreadcrumbHeading').length).toBe(1); expect(wrapper.find('Title').length).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });
}); });

View File

@@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) {
const { summary_fields = {} } = project; const { summary_fields = {} } = project;
const [contentError, setContentError] = useState(null); const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [scmSubFormState, setScmSubFormState] = useState(null); const [scmSubFormState, setScmSubFormState] = useState({
scm_url: '',
scm_branch: '',
scm_refspec: '',
credential: '',
scm_clean: false,
scm_delete_on_update: false,
scm_update_on_launch: false,
allow_override: false,
scm_update_cache_timeout: 0,
});
const [scmTypeOptions, setScmTypeOptions] = useState(null); const [scmTypeOptions, setScmTypeOptions] = useState(null);
const [credentials, setCredentials] = useState({ const [credentials, setCredentials] = useState({
scm: { typeId: null, value: null }, scm: { typeId: null, value: null },

View File

@@ -6,8 +6,8 @@ import { SyncIcon } from '@patternfly/react-icons';
import { number } from 'prop-types'; import { number } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail'; import ErrorDetail from '../../../components/ErrorDetail';
import { ProjectsAPI } from '../../../api'; import { ProjectsAPI } from '../../../api';

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import { ScheduleList } from '../../components/Schedule'; import { ScheduleList } from '../../components/Schedule';
import { SchedulesAPI } from '../../api'; import { SchedulesAPI } from '../../api';
@@ -19,7 +19,8 @@ function AllSchedules({ i18n }) {
return ( return (
<> <>
<Breadcrumbs <ScreenHeader
streamType="schedule"
breadcrumbConfig={{ breadcrumbConfig={{
'/schedules': i18n._(t`Schedules`), '/schedules': i18n._(t`Schedules`),
}} }}

View File

@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AllSchedules from './AllSchedules'; import AllSchedules from './AllSchedules';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<AllSchedules />', () => { describe('<AllSchedules />', () => {
let wrapper; let wrapper;
@@ -30,7 +34,6 @@ describe('<AllSchedules />', () => {
}, },
}); });
expect(wrapper.find('Crumb').length).toBe(1); expect(wrapper.find('Title').text()).toBe('Schedules');
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules');
}); });
}); });

View File

@@ -2,12 +2,18 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import RADIUS from './RADIUS'; import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api'; import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import RADIUS from './RADIUS';
jest.mock('../../../api/models/Settings'); jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({ SettingsAPI.readCategory.mockResolvedValue({
data: {}, data: {
RADIUS_SERVER: 'radius.example.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '$encrypted$',
},
}); });
describe('<RADIUS />', () => { describe('<RADIUS />', () => {
@@ -23,9 +29,14 @@ describe('<RADIUS />', () => {
initialEntries: ['/settings/radius/details'], initialEntries: ['/settings/radius/details'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<RADIUS />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <RADIUS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('RADIUSDetail').length).toBe(1); expect(wrapper.find('RADIUSDetail').length).toBe(1);
}); });
@@ -35,9 +46,14 @@ describe('<RADIUS />', () => {
initialEntries: ['/settings/radius/edit'], initialEntries: ['/settings/radius/edit'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<RADIUS />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <RADIUS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('RADIUSEdit').length).toBe(1); expect(wrapper.find('RADIUSEdit').length).toBe(1);
}); });

View File

@@ -1,25 +1,113 @@
import React from 'react'; import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { Formik } from 'formik';
import { t } from '@lingui/macro'; import { Form } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core'; import { CardBody } from '../../../../components/Card';
import { CardBody, CardActionsRow } from '../../../../components/Card'; import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import { EncryptedField, InputField } from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function RADIUSEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchRadius, result: radius } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('radius');
const mergedData = {};
Object.keys(data).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchRadius();
}, [fetchRadius]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/radius/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm(form);
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(radius).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/radius/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
acc[key] = fields[key].value ?? '';
return acc;
}, {});
function RADIUSEdit({ i18n }) {
return ( return (
<CardBody> <CardBody>
{i18n._(t`Edit form coming soon :)`)} {isLoading && <ContentLoading />}
<CardActionsRow> {!isLoading && error && <ContentError error={error} />}
<Button {!isLoading && radius && (
aria-label={i18n._(t`Cancel`)} <Formik initialValues={initialValues(radius)} onSubmit={handleSubmit}>
component={Link} {formik => (
to="/settings/radius/details" <Form autoComplete="off" onSubmit={formik.handleSubmit}>
> <FormColumnLayout>
{i18n._(t`Cancel`)} <InputField
</Button> name="RADIUS_SERVER"
</CardActionsRow> config={radius.RADIUS_SERVER}
/>
<InputField name="RADIUS_PORT" config={radius.RADIUS_PORT} />
<EncryptedField
name="RADIUS_SECRET"
config={radius.RADIUS_SECRET}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody> </CardBody>
); );
} }
export default withI18n()(RADIUSEdit); export default RADIUSEdit;

View File

@@ -1,16 +1,149 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import RADIUSEdit from './RADIUSEdit'; import RADIUSEdit from './RADIUSEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
RADIUS_SERVER: 'radius.mock.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '$encrypted$',
},
});
describe('<RADIUSEdit />', () => { describe('<RADIUSEdit />', () => {
let wrapper; let wrapper;
beforeEach(() => { let history;
wrapper = mountWithContexts(<RADIUSEdit />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks();
}); });
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/radius/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('RADIUSEdit').length).toBe(1); expect(wrapper.find('RADIUSEdit').length).toBe(1);
}); });
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="RADIUS Server"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="RADIUS Port"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="RADIUS Secret"]').length).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
RADIUS_SERVER: '',
RADIUS_PORT: 1812,
RADIUS_SECRET: '',
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#RADIUS_SERVER').simulate('change', {
target: { value: 'radius.new_mock.org', name: 'RADIUS_SERVER' },
});
wrapper
.find('FormGroup[fieldId="RADIUS_SECRET"] button[aria-label="Revert"]')
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
RADIUS_SERVER: 'radius.new_mock.org',
RADIUS_PORT: 1812,
RADIUS_SECRET: '',
});
});
test('should navigate to radius detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/radius/details');
});
test('should navigate to radius detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/radius/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<RADIUSEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
}); });

View File

@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { PageSection, Card } from '@patternfly/react-core'; import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import ActivityStream from './ActivityStream'; import ActivityStream from './ActivityStream';
import AzureAD from './AzureAD'; import AzureAD from './AzureAD';
import GitHub from './GitHub'; import GitHub from './GitHub';
@@ -129,7 +129,7 @@ function Settings({ i18n }) {
return ( return (
<SettingsProvider value={result}> <SettingsProvider value={result}>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="setting" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/settings/activity_stream"> <Route path="/settings/activity_stream">
<ActivityStream /> <ActivityStream />

View File

@@ -13,6 +13,9 @@ jest.mock('../../api/models/Settings');
SettingsAPI.readAllOptions.mockResolvedValue({ SettingsAPI.readAllOptions.mockResolvedValue({
data: mockAllOptions, data: mockAllOptions,
}); });
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Settings />', () => { describe('<Settings />', () => {
let wrapper; let wrapper;

View File

@@ -2,12 +2,20 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api'; import { SettingsAPI } from '../../../api';
import mockAllOptions from '../shared/data.allSettingOptions.json';
import TACACS from './TACACS'; import TACACS from './TACACS';
jest.mock('../../../api/models/Settings'); jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({ SettingsAPI.readCategory.mockResolvedValue({
data: {}, data: {
TACACSPLUS_HOST: 'mockhost',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
},
}); });
describe('<TACACS />', () => { describe('<TACACS />', () => {
@@ -23,9 +31,14 @@ describe('<TACACS />', () => {
initialEntries: ['/settings/tacacs/details'], initialEntries: ['/settings/tacacs/details'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TACACS />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <TACACS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('TACACSDetail').length).toBe(1); expect(wrapper.find('TACACSDetail').length).toBe(1);
}); });
@@ -35,9 +48,14 @@ describe('<TACACS />', () => {
initialEntries: ['/settings/tacacs/edit'], initialEntries: ['/settings/tacacs/edit'],
}); });
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<TACACS />, { wrapper = mountWithContexts(
context: { router: { history } }, <SettingsProvider value={mockAllOptions.actions}>
}); <TACACS />
</SettingsProvider>,
{
context: { router: { history } },
}
);
}); });
expect(wrapper.find('TACACSEdit').length).toBe(1); expect(wrapper.find('TACACSEdit').length).toBe(1);
}); });

View File

@@ -1,25 +1,130 @@
import React from 'react'; import React, { useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { Formik } from 'formik';
import { t } from '@lingui/macro'; import { Form } from '@patternfly/react-core';
import { Button } from '@patternfly/react-core'; import { CardBody } from '../../../../components/Card';
import { CardBody, CardActionsRow } from '../../../../components/Card'; import ContentError from '../../../../components/ContentError';
import ContentLoading from '../../../../components/ContentLoading';
import { FormSubmitError } from '../../../../components/FormField';
import { FormColumnLayout } from '../../../../components/FormLayout';
import { useSettings } from '../../../../contexts/Settings';
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
import {
ChoiceField,
EncryptedField,
InputField,
} from '../../shared/SharedFields';
import useModal from '../../../../util/useModal';
import useRequest from '../../../../util/useRequest';
import { SettingsAPI } from '../../../../api';
function TACACSEdit() {
const history = useHistory();
const { isModalOpen, toggleModal, closeModal } = useModal();
const { PUT: options } = useSettings();
const { isLoading, error, request: fetchTACACS, result: tacacs } = useRequest(
useCallback(async () => {
const { data } = await SettingsAPI.readCategory('tacacsplus');
const mergedData = {};
Object.keys(data).forEach(key => {
mergedData[key] = options[key];
mergedData[key].value = data[key];
});
return mergedData;
}, [options]),
null
);
useEffect(() => {
fetchTACACS();
}, [fetchTACACS]);
const { error: submitError, request: submitForm } = useRequest(
useCallback(
async values => {
await SettingsAPI.updateAll(values);
history.push('/settings/tacacs/details');
},
[history]
),
null
);
const handleSubmit = async form => {
await submitForm(form);
};
const handleRevertAll = async () => {
const defaultValues = Object.assign(
...Object.entries(tacacs).map(([key, value]) => ({
[key]: value.default,
}))
);
await submitForm(defaultValues);
closeModal();
};
const handleCancel = () => {
history.push('/settings/tacacs/details');
};
const initialValues = fields =>
Object.keys(fields).reduce((acc, key) => {
acc[key] = fields[key].value ?? '';
return acc;
}, {});
function TACACSEdit({ i18n }) {
return ( return (
<CardBody> <CardBody>
{i18n._(t`Edit form coming soon :)`)} {isLoading && <ContentLoading />}
<CardActionsRow> {!isLoading && error && <ContentError error={error} />}
<Button {!isLoading && tacacs && (
aria-label={i18n._(t`Cancel`)} <Formik initialValues={initialValues(tacacs)} onSubmit={handleSubmit}>
component={Link} {formik => (
to="/settings/tacacs/details" <Form autoComplete="off" onSubmit={formik.handleSubmit}>
> <FormColumnLayout>
{i18n._(t`Cancel`)} <InputField
</Button> name="TACACSPLUS_HOST"
</CardActionsRow> config={tacacs.TACACSPLUS_HOST}
/>
<InputField
name="TACACSPLUS_PORT"
config={tacacs.TACACSPLUS_PORT}
type="number"
/>
<EncryptedField
name="TACACSPLUS_SECRET"
config={tacacs.TACACSPLUS_SECRET}
/>
<InputField
name="TACACSPLUS_SESSION_TIMEOUT"
config={tacacs.TACACSPLUS_SESSION_TIMEOUT}
type="number"
/>
<ChoiceField
name="TACACSPLUS_AUTH_PROTOCOL"
config={tacacs.TACACSPLUS_AUTH_PROTOCOL}
/>
{submitError && <FormSubmitError error={submitError} />}
</FormColumnLayout>
<RevertFormActionGroup
onCancel={handleCancel}
onSubmit={formik.handleSubmit}
onRevert={toggleModal}
/>
{isModalOpen && (
<RevertAllAlert
onClose={closeModal}
onRevertAll={handleRevertAll}
/>
)}
</Form>
)}
</Formik>
)}
</CardBody> </CardBody>
); );
} }
export default withI18n()(TACACSEdit); export default TACACSEdit;

View File

@@ -1,16 +1,166 @@
import React from 'react'; import React from 'react';
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../../testUtils/enzymeHelpers';
import mockAllOptions from '../../shared/data.allSettingOptions.json';
import { SettingsProvider } from '../../../../contexts/Settings';
import { SettingsAPI } from '../../../../api';
import TACACSEdit from './TACACSEdit'; import TACACSEdit from './TACACSEdit';
jest.mock('../../../../api/models/Settings');
SettingsAPI.updateAll.mockResolvedValue({});
SettingsAPI.readCategory.mockResolvedValue({
data: {
TACACSPLUS_HOST: 'mockhost',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
},
});
describe('<TACACSEdit />', () => { describe('<TACACSEdit />', () => {
let wrapper; let wrapper;
beforeEach(() => { let history;
wrapper = mountWithContexts(<TACACSEdit />);
});
afterEach(() => { afterEach(() => {
wrapper.unmount(); wrapper.unmount();
jest.clearAllMocks();
}); });
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/settings/tacacs/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSEdit />
</SettingsProvider>,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('initially renders without crashing', () => { test('initially renders without crashing', () => {
expect(wrapper.find('TACACSEdit').length).toBe(1); expect(wrapper.find('TACACSEdit').length).toBe(1);
}); });
test('should display expected form fields', async () => {
expect(wrapper.find('FormGroup[label="TACACS+ Server"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="TACACS+ Port"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="TACACS+ Secret"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="TACACS+ Auth Session Timeout"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="TACACS+ Authentication Protocol"]').length
).toBe(1);
});
test('should successfully send default values to api on form revert all', async () => {
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
await act(async () => {
wrapper
.find('button[aria-label="Revert all to default"]')
.invoke('onClick')();
});
wrapper.update();
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
await act(async () => {
wrapper
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
.invoke('onClick')();
});
wrapper.update();
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
TACACSPLUS_HOST: '',
TACACSPLUS_PORT: 49,
TACACSPLUS_SECRET: '',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
});
});
test('should successfully send request to api on form submission', async () => {
act(() => {
wrapper.find('input#TACACSPLUS_HOST').simulate('change', {
target: { value: 'new_host', name: 'TACACSPLUS_HOST' },
});
wrapper.find('input#TACACSPLUS_PORT').simulate('change', {
target: { value: 999, name: 'TACACSPLUS_PORT' },
});
wrapper
.find(
'FormGroup[fieldId="TACACSPLUS_SECRET"] button[aria-label="Revert"]'
)
.invoke('onClick')();
});
wrapper.update();
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
TACACSPLUS_HOST: 'new_host',
TACACSPLUS_PORT: 999,
TACACSPLUS_SECRET: '',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
});
});
test('should navigate to tacacs detail on successful submission', async () => {
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(history.location.pathname).toEqual('/settings/tacacs/details');
});
test('should navigate to tacacs detail when cancel is clicked', async () => {
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual('/settings/tacacs/details');
});
test('should display error message on unsuccessful submission', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
expect(wrapper.find('FormSubmitError').length).toBe(0);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
test('should display ContentError on throw', async () => {
SettingsAPI.readCategory.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockAllOptions.actions}>
<TACACSEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
}); });

View File

@@ -27,71 +27,68 @@ const DataListAction = styled(_DataListAction)`
grid-template-columns: 40px; grid-template-columns: 40px;
`; `;
class TeamListItem extends React.Component { function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) {
static propTypes = { TeamListItem.propTypes = {
team: Team.isRequired, team: Team.isRequired,
detailUrl: string.isRequired, detailUrl: string.isRequired,
isSelected: bool.isRequired, isSelected: bool.isRequired,
onSelect: func.isRequired, onSelect: func.isRequired,
}; };
render() { const labelId = `check-action-${team.id}`;
const { team, isSelected, onSelect, detailUrl, i18n } = this.props;
const labelId = `check-action-${team.id}`;
return ( return (
<DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}> <DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}>
<DataListItemRow> <DataListItemRow>
<DataListCheck <DataListCheck
id={`select-team-${team.id}`} id={`select-team-${team.id}`}
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={onSelect}
aria-labelledby={labelId} aria-labelledby={labelId}
/> />
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key="name"> <DataListCell key="name">
<Link id={labelId} to={`${detailUrl}`}> <Link id={labelId} to={`${detailUrl}`}>
<b>{team.name}</b> <b>{team.name}</b>
</Link> </Link>
</DataListCell>, </DataListCell>,
<DataListCell key="organization"> <DataListCell key="organization">
{team.summary_fields.organization && ( {team.summary_fields.organization && (
<Fragment> <Fragment>
<b>{i18n._(t`Organization`)}</b>{' '} <b>{i18n._(t`Organization`)}</b>{' '}
<Link <Link
to={`/organizations/${team.summary_fields.organization.id}/details`} to={`/organizations/${team.summary_fields.organization.id}/details`}
> >
<b>{team.summary_fields.organization.name}</b> <b>{team.summary_fields.organization.name}</b>
</Link> </Link>
</Fragment> </Fragment>
)} )}
</DataListCell>, </DataListCell>,
]} ]}
/> />
<DataListAction <DataListAction
aria-label="actions" aria-label="actions"
aria-labelledby={labelId} aria-labelledby={labelId}
id={labelId} id={labelId}
> >
{team.summary_fields.user_capabilities.edit ? ( {team.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Team`)} position="top"> <Tooltip content={i18n._(t`Edit Team`)} position="top">
<Button <Button
aria-label={i18n._(t`Edit Team`)} aria-label={i18n._(t`Edit Team`)}
variant="plain" variant="plain"
component={Link} component={Link}
to={`/teams/${team.id}/edit`} to={`/teams/${team.id}/edit`}
> >
<PencilAltIcon /> <PencilAltIcon />
</Button> </Button>
</Tooltip> </Tooltip>
) : ( ) : (
'' ''
)} )}
</DataListAction> </DataListAction>
</DataListItemRow> </DataListItemRow>
</DataListItem> </DataListItem>
); );
}
} }
export default withI18n()(TeamListItem); export default withI18n()(TeamListItem);

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Config } from '../../contexts/Config'; import { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs'; import ScreenHeader from '../../components/ScreenHeader';
import TeamList from './TeamList'; import TeamList from './TeamList';
import TeamAdd from './TeamAdd'; import TeamAdd from './TeamAdd';
import Team from './Team'; import Team from './Team';
@@ -29,6 +29,7 @@ function Teams({ i18n }) {
[`/teams/${team.id}/details`]: i18n._(t`Details`), [`/teams/${team.id}/details`]: i18n._(t`Details`),
[`/teams/${team.id}/users`]: i18n._(t`Users`), [`/teams/${team.id}/users`]: i18n._(t`Users`),
[`/teams/${team.id}/access`]: i18n._(t`Access`), [`/teams/${team.id}/access`]: i18n._(t`Access`),
[`/teams/${team.id}/roles`]: i18n._(t`Roles`),
}); });
}, },
[i18n] [i18n]
@@ -36,7 +37,7 @@ function Teams({ i18n }) {
return ( return (
<> <>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} /> <ScreenHeader streamType="team" breadcrumbConfig={breadcrumbConfig} />
<Switch> <Switch>
<Route path="/teams/add"> <Route path="/teams/add">
<TeamAdd /> <TeamAdd />

Some files were not shown because too many files have changed in this diff Show More