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
commit ac280e1446
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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>`.
# 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)
- 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

View File

@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor
*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*

View File

@ -1 +1 @@
16.0.0
17.0.0

View File

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

View File

@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
# events into the database
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()
JOB_EVENT_BUFFER_SECONDS = 1
JOB_EVENT_BUFFER_SECONDS = .1
# The interval at which callback receiver statistics should be
# recorded

View File

@ -1,3 +1,4 @@
import ActivityStream from './models/ActivityStream';
import AdHocCommands from './models/AdHocCommands';
import Applications from './models/Applications';
import Auth from './models/Auth';
@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
import WorkflowJobs from './models/WorkflowJobs';
const ActivityStreamAPI = new ActivityStream();
const AdHocCommandsAPI = new AdHocCommands();
const ApplicationsAPI = new Applications();
const AuthAPI = new Auth();
@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
const WorkflowJobsAPI = new WorkflowJobs();
export {
ActivityStreamAPI,
AdHocCommandsAPI,
ApplicationsAPI,
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 { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams);
const readTeamsOptions = async () => TeamsAPI.readOptions();
class AddResourceRole extends React.Component {
constructor(props) {
super(props);
this.state = {
selectedResource: null,
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;
function AddResourceRole({ onSave, onClose, roles, i18n, resource }) {
const [selectedResource, setSelectedResource] = useState(null);
const [selectedResourceRows, setSelectedResourceRows] = useState([]);
const [selectedRoleRows, setSelectedRoleRows] = useState([]);
const [currentStepId, setCurrentStepId] = useState(1);
const [maxEnabledStep, setMaxEnabledStep] = useState(1);
const handleResourceCheckboxClick = user => {
const selectedIndex = selectedResourceRows.findIndex(
selectedRow => selectedRow.id === user.id
);
if (selectedIndex > -1) {
selectedResourceRows.splice(selectedIndex, 1);
const stateToUpdate = { selectedResourceRows };
if (selectedResourceRows.length === 0) {
stateToUpdate.maxEnabledStep = currentStepId;
setMaxEnabledStep(currentStepId);
}
this.setState(stateToUpdate);
setSelectedRoleRows(selectedResourceRows);
} else {
this.setState(prevState => ({
selectedResourceRows: [...prevState.selectedResourceRows, user],
}));
setSelectedResourceRows([...selectedResourceRows, user]);
}
}
handleRoleCheckboxClick(role) {
const { selectedRoleRows } = this.state;
};
const handleRoleCheckboxClick = role => {
const selectedIndex = selectedRoleRows.findIndex(
selectedRow => selectedRow.id === role.id
);
if (selectedIndex > -1) {
selectedRoleRows.splice(selectedIndex, 1);
this.setState({ selectedRoleRows });
setSelectedRoleRows(selectedRoleRows);
} else {
this.setState(prevState => ({
selectedRoleRows: [...prevState.selectedRoleRows, role],
}));
setSelectedRoleRows([...selectedRoleRows, role]);
}
}
};
handleResourceSelect(resourceType) {
this.setState({
selectedResource: resourceType,
selectedResourceRows: [],
selectedRoleRows: [],
});
}
const handleResourceSelect = resourceType => {
setSelectedResource(resourceType);
setSelectedResourceRows([]);
setSelectedRoleRows([]);
};
handleWizardNext(step) {
this.setState({
currentStepId: step.id,
maxEnabledStep: step.id,
});
}
const handleWizardNext = step => {
setCurrentStepId(step.id);
setMaxEnabledStep(step.id);
};
handleWizardGoToStep(step) {
this.setState({
currentStepId: step.id,
});
}
async handleWizardSave() {
const { onSave } = this.props;
const {
selectedResourceRows,
selectedRoleRows,
selectedResource,
} = this.state;
const handleWizardGoToStep = step => {
setCurrentStepId(step.id);
};
const handleWizardSave = async () => {
try {
const roleRequests = [];
@ -134,201 +96,186 @@ class AddResourceRole extends React.Component {
} catch (err) {
// 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 {
selectedResource,
selectedResourceRows,
selectedRoleRows,
currentStepId,
maxEnabledStep,
} = this.state;
const { onClose, roles, i18n, resource } = this.props;
const userSearchColumns = [
{
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 = [
{
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
// 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];
}
});
}
const teamSortColumns = [
{
name: i18n._(t`Name`),
key: 'name',
},
];
const userSearchColumns = [
{
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',
},
];
let wizardTitle = '';
const userSortColumns = [
{
name: i18n._(t`Username`),
key: 'username',
},
{
name: i18n._(t`First Name`),
key: 'first_name',
},
{
name: i18n._(t`Last Name`),
key: 'last_name',
},
];
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 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',
},
];
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')}
/>
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>
),
enableNext: selectedResource !== null,
},
{
id: 2,
name: i18n._(t`Select Items from List`),
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}
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
ariaLabel={i18n._(t`Users`)}
dataCy="add-role-users"
onClick={() => handleResourceSelect('users')}
/>
),
nextButtonText: i18n._(t`Save`),
enableNext: selectedRoleRows.length > 0,
canJumpTo: maxEnabledStep >= 3,
},
];
{resource?.type === 'credential' && !resource?.organization ? null : (
<SelectableCard
isSelected={selectedResource === 'teams'}
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
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
onNext={this.handleWizardNext}
onClose={onClose}
onSave={this.handleWizardSave}
onGoToStep={this.handleWizardGoToStep}
steps={steps}
title={wizardTitle}
nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
/>
);
}
// TODO: somehow internationalize steps and currentStep.nextButtonText
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
onNext={handleWizardNext}
onClose={onClose}
onSave={handleWizardSave}
onGoToStep={step => handleWizardGoToStep(step)}
steps={steps}
title={wizardTitle}
nextButtonText={currentStep.nextButtonText || undefined}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
/>
);
}
AddResourceRole.propTypes = {

View File

@ -1,22 +1,46 @@
/* eslint-disable react/jsx-pascal-case */
import React from 'react';
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 { 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 />', () => {
UsersAPI.read.mockResolvedValue({
data: {
count: 2,
results: [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' },
{ id: 1, username: 'foo', url: '' },
{ 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 = {
admin_role: {
description: 'Can manage all aspects of the organization',
@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => {
/>
);
});
test('handleRoleCheckboxClick properly updates state', () => {
const wrapper = shallow(
<_AddResourceRole
onClose={() => {}}
onSave={() => {}}
roles={roles}
i18n={{ _: val => val.toString() }}
/>
);
wrapper.setState({
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 1,
},
],
test('should save properly', async () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
);
});
wrapper.instance().handleRoleCheckboxClick({
description: 'Can manage all aspects of the organization',
name: 'Admin',
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');
wrapper.update();
// Step 1
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
selectableCardWrapper.first().simulate('click');
expect(spy).toHaveBeenCalledWith('users');
expect(wrapper.state('selectedResource')).toBe('users');
selectableCardWrapper.at(1).simulate('click');
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() }}
/>
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
wrapper.update();
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
username: 'foobar',
},
],
selectedRoleRows: [
{
description: 'Can manage all aspects of the organization',
id: 1,
name: 'Admin',
},
],
});
wrapper.instance().handleResourceSelect('users');
expect(wrapper.state()).toEqual({
selectedResource: 'users',
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
});
wrapper.instance().handleResourceSelect('teams');
expect(wrapper.state()).toEqual({
selectedResource: 'teams',
selectedResourceRows: [],
selectedRoleRows: [],
currentStepId: 1,
maxEnabledStep: 1,
});
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
);
// Save
await act(async () =>
wrapper.find('Button[type="submit"]').prop('onClick')()
);
expect(UsersAPI.associateRole).toBeCalledWith(1, 1);
});
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
const handleSave = jest.fn();
const wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
wrapper.setState({
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',
},
],
test('should successfuly click user/team cards', async () => {
let wrapper;
act(() => {
wrapper = mountWithContexts(
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
{ context: { network: { handleHttpError: () => {} } } }
);
});
await wrapper.instance().handleWizardSave();
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
wrapper.setState({
selectedResource: 'teams',
selectedResourceRows: [
{
id: 1,
name: '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',
},
],
wrapper.update();
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(2);
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
wrapper.update();
await waitForElement(
wrapper,
'SelectableCard[label="Users"]',
el => el.prop('isSelected') === true
);
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
wrapper.update();
await waitForElement(
wrapper,
'SelectableCard[label="Teams"]',
el => el.prop('isSelected') === true
);
});
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();
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
wrapper.update();
// 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', () => {
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
const wrapper = mountWithContexts(
<AddResourceRole
onClose={() => {}}
@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => {
resource={{ type: 'credential', organization: null }}
/>,
{ context: { network: { handleHttpError: () => {} } } }
).find('AddResourceRole');
const selectableCardWrapper = wrapper.find('SelectableCard');
expect(selectableCardWrapper.length).toBe(1);
selectableCardWrapper.first().simulate('click');
expect(spy).toHaveBeenCalledWith('users');
expect(wrapper.state('selectedResource')).toBe('users');
);
expect(wrapper.find('SelectableCard').length).toBe(1);
wrapper.find('SelectableCard[label="Users"]').simulate('click');
wrapper.update();
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 SelectedList from '../SelectedList';
class RolesStep extends React.Component {
render() {
const {
onRolesClick,
roles,
selectedListKey,
selectedListLabel,
selectedResourceRows,
selectedRoleRows,
i18n,
} = this.props;
return (
<Fragment>
<div>
{i18n._(
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
)}
</div>
<div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={selectedListKey}
isReadOnly
label={selectedListLabel || i18n._(t`Selected`)}
selected={selectedResourceRows}
/>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px 20px',
marginTop: '20px',
}}
>
{Object.keys(roles).map(role => (
<CheckboxCard
description={roles[role].description}
itemId={roles[role].id}
isSelected={selectedRoleRows.some(
item => item.id === roles[role].id
)}
key={roles[role].id}
name={roles[role].name}
onSelect={() => onRolesClick(roles[role])}
/>
))}
</div>
</Fragment>
);
}
function RolesStep({
onRolesClick,
roles,
selectedListKey,
selectedListLabel,
selectedResourceRows,
selectedRoleRows,
i18n,
}) {
return (
<Fragment>
<div>
{i18n._(
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
)}
</div>
<div>
{selectedResourceRows.length > 0 && (
<SelectedList
displayKey={selectedListKey}
isReadOnly
label={selectedListLabel || i18n._(t`Selected`)}
selected={selectedResourceRows}
/>
)}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px 20px',
marginTop: '20px',
}}
>
{Object.keys(roles).map(role => (
<CheckboxCard
description={roles[role].description}
itemId={roles[role].id}
isSelected={selectedRoleRows.some(
item => item.id === roles[role].id
)}
key={roles[role].id}
name={roles[role].name}
onSelect={() => onRolesClick(roles[role])}
/>
))}
</div>
</Fragment>
);
}
RolesStep.propTypes = {

View File

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

View File

@ -1,6 +1,6 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
import AnsibleSelect from './AnsibleSelect';
const mockData = [
{
@ -16,6 +16,7 @@ const mockData = [
];
describe('<AnsibleSelect />', () => {
const onChange = jest.fn();
test('initially renders succesfully', async () => {
mountWithContexts(
<AnsibleSelect
@ -29,19 +30,18 @@ describe('<AnsibleSelect />', () => {
});
test('calls "onSelectChange" on dropdown select change', () => {
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
const wrapper = mountWithContexts(
<AnsibleSelect
id="bar"
value="foo"
name="bar"
onChange={() => {}}
onChange={onChange}
data={mockData}
/>
);
expect(spy).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
wrapper.find('select').simulate('change');
expect(spy).toHaveBeenCalled();
expect(onChange).toHaveBeenCalled();
});
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
// with ExpandingContainer
class ExpandCollapse extends React.Component {
render() {
const { isCompact, onCompact, onExpand, i18n } = this.props;
return (
<Fragment>
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Collapse`)}
onClick={onCompact}
isActive={isCompact}
>
<BarsIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Expand`)}
onClick={onExpand}
isActive={!isCompact}
>
<EqualsIcon />
</Button>
</ToolbarItem>
</Fragment>
);
}
function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) {
return (
<Fragment>
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Collapse`)}
onClick={onCompact}
isActive={isCompact}
>
<BarsIcon />
</Button>
</ToolbarItem>
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Expand`)}
onClick={onExpand}
isActive={!isCompact}
>
<EqualsIcon />
</Button>
</ToolbarItem>
</Fragment>
);
}
ExpandCollapse.propTypes = {

View File

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

View File

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

View File

@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import { sleep } from '../../../testUtils/testUtils';
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/models/JobTemplates');
jest.mock('../../api');
describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({
@ -22,10 +28,14 @@ describe('LaunchButton', () => {
},
});
const children = ({ handleLaunch }) => (
const launchButton = ({ handleLaunch }) => (
<button type="submit" onClick={() => handleLaunch()} />
);
const relaunchButton = ({ handleRelaunch }) => (
<button type="submit" onClick={() => handleRelaunch()} />
);
const resource = {
id: 1,
type: 'job_template',
@ -35,7 +45,7 @@ describe('LaunchButton', () => {
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
);
expect(wrapper).toHaveLength(1);
});
@ -51,7 +61,7 @@ describe('LaunchButton', () => {
},
});
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>,
<LaunchButton resource={resource}>{launchButton}</LaunchButton>,
{
context: {
router: { history },
@ -87,7 +97,7 @@ describe('LaunchButton', () => {
type: 'workflow_job_template',
}}
>
{children}
{launchButton}
</LaunchButton>,
{
context: {
@ -100,12 +110,162 @@ describe('LaunchButton', () => {
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0);
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 () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
);
JobTemplatesAPI.launch.mockRejectedValue(
new Error({

View File

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

View File

@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
ask_credential_on_launch: true,
ask_scm_branch_on_launch: 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}
onCancel={noop}
surveyConfig={{
@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
const wizard = await waitForElement(wrapper, 'Wizard');
const steps = wizard.prop('steps');
expect(steps).toHaveLength(5);
expect(steps).toHaveLength(6);
expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name.props.children).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other prompts');
expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name.props.children).toEqual('Preview');
expect(steps[2].name.props.children).toEqual('Credential passwords');
expect(steps[3].name.props.children).toEqual('Other prompts');
expect(steps[4].name.props.children).toEqual('Survey');
expect(steps[5].name.props.children).toEqual('Preview');
});
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 { t } from '@lingui/macro';
import { useField } from 'formik';
import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest from '../../../util/useRequest';
@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
});
function InventoryStep({ i18n }) {
const [field, , helpers] = useField({
const [field, meta, helpers] = useField({
name: 'inventory',
});
const history = useHistory();
const {
@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
}
return (
<OptionsList
value={field.value ? [field.value] : []}
options={inventories}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly
selectItem={helpers.setValue}
deselectItem={() => field.onChange(null)}
/>
<>
<OptionsList
value={field.value ? [field.value] : []}
options={inventories}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly
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 {
step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
validate: () => ({}),
isReady: true,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
credentials: true,
});
hasError: false,
setTouched: setFieldTouched => {
setFieldTouched('credentials', true, false);
},
validate: () => {},
};
}

View File

@ -12,20 +12,27 @@ export default function useInventoryStep(
i18n,
visitedSteps
) {
const [, meta] = useField('inventory');
const [, meta, helpers] = useField('inventory');
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 {
step: getStep(launchConfig, i18n, formError),
initialValues: getInitialValues(launchConfig, resource),
isReady: true,
contentError: null,
formError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
});
hasError: launchConfig.ask_inventory_on_launch && formError,
setTouched: setFieldTouched => {
setFieldTouched('inventory', true, false);
},
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),
isReady: true,
contentError: null,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,
limit: true,
verbosity: true,
diff_mode: true,
job_tags: true,
skip_tags: true,
extra_vars: true,
});
hasError: false,
setTouched: setFieldTouched => {
[
'job_type',
'limit',
'verbosity',
'diff_mode',
'job_tags',
'skip_tags',
'extra_vars',
].forEach(field => setFieldTouched(field, true, false));
},
validate: () => {},
};
}

View File

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

View File

@ -13,89 +13,51 @@ export default function useSurveyStep(
i18n,
visitedSteps
) {
const { values } = useFormikContext();
const errors = {};
const validate = () => {
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
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;
const { setFieldError, values } = useFormikContext();
const hasError =
Object.keys(visitedSteps).includes(STEP_ID) &&
checkForError(launchConfig, surveyConfig, values);
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),
validate,
surveyConfig,
isReady: true,
contentError: null,
formError,
setTouched: setFieldsTouched => {
hasError,
setTouched: setFieldTouched => {
if (!surveyConfig?.spec) {
return;
}
const fields = {};
surveyConfig.spec.forEach(question => {
fields[`survey_${question.variable}`] = true;
setFieldTouched(`survey_${question.variable}`, true, false);
});
setFieldsTouched(fields);
},
};
}
function validateField(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 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,
validate: () => {
if (launchConfig.survey_enabled && surveyConfig.spec) {
surveyConfig.spec.forEach(question => {
const errMessage = validateSurveyField(
question,
values[`survey_${question.variable}`],
i18n
);
if (errMessage) {
setFieldError(`survey_${question.variable}`, errMessage);
}
});
}
},
};
}
@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
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 useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep';
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep';
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(
launchConfig,
surveyConfig,
@ -14,14 +47,21 @@ export default function useLaunchSteps(
) {
const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false);
const { touched, values: formikValues } = useFormikContext();
const steps = [
useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n),
useCredentialPasswordsStep(
launchConfig,
i18n,
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
visited
),
useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
const { resetForm } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
const hasErrors = steps.some(step => step.hasError);
steps.push(
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
@ -38,16 +78,47 @@ export default function useLaunchSteps(
...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({
values: {
...initialValues,
},
values: newFormValues,
touched,
});
setIsReady(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsAreReady]);
}, [formikValues.credentials, stepsAreReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
@ -55,20 +126,26 @@ export default function useLaunchSteps(
return {
steps: pfSteps,
isReady,
visitStep: stepId =>
validateStep: stepId => {
steps.find(s => s?.step?.id === stepId).validate();
},
visitStep: (prevStepId, setFieldTouched) => {
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
[prevStepId]: true,
});
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
},
visitAllSteps: setFieldTouched => {
setVisited({
inventory: true,
credentials: true,
credentialPasswords: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
steps.forEach(s => s.setTouched(setFieldTouched));
},
contentError,
};

View File

@ -85,7 +85,12 @@ class ListHeader extends React.Component {
pushHistoryState(params) {
const { history, qsConfig } = this.props;
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);
}

View File

@ -12,6 +12,7 @@ import {
FormGroup,
InputGroup,
Modal,
Tooltip,
} from '@patternfly/react-core';
import ChipGroup from '../ChipGroup';
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 (
<FormGroup
fieldId="host-filter"
@ -261,33 +292,17 @@ function HostFilterLookup({
/>
}
>
<InputGroup onBlur={onBlur}>
<Button
aria-label={i18n._(t`Search`)}
id="host-filter"
isDisabled={isDisabled}
onClick={handleOpenModal}
variant={ButtonVariant.control}
{isDisabled ? (
<Tooltip
content={i18n._(
t`Please select an organization before editing the host filter`
)}
>
<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>
{renderLookup()}
</Tooltip>
) : (
renderLookup()
)}
<Modal
aria-label={i18n._(t`Lookup modal`)}
isOpen={isModalOpen}

View File

@ -61,7 +61,12 @@ function PaginatedDataList({
};
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);
};

View File

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

View File

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

View File

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

View File

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

View File

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

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 { mount } from 'enzyme';
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 breadcrumb;
let breadcrumbItem;
@ -17,15 +23,15 @@ describe('<Breadcrumb />', () => {
};
const findChildren = () => {
breadcrumb = breadcrumbWrapper.find('Breadcrumb');
breadcrumb = breadcrumbWrapper.find('ScreenHeader');
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading');
breadcrumbHeading = breadcrumbWrapper.find('Title');
};
test('initially renders succesfully', () => {
breadcrumbWrapper = mount(
breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
<Breadcrumbs breadcrumbConfig={config} />
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</MemoryRouter>
);
@ -51,9 +57,9 @@ describe('<Breadcrumb />', () => {
];
routes.forEach(([location, crumbLength]) => {
breadcrumbWrapper = mount(
breadcrumbWrapper = mountWithContexts(
<MemoryRouter initialEntries={[location]}>
<Breadcrumbs breadcrumbConfig={config} />
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
</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 { withI18n } from '@lingui/react';
import { withRouter } from 'react-router-dom';
import { useLocation, withRouter } from 'react-router-dom';
import { t } from '@lingui/macro';
import {
Button,
@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div`
border-bottom-color: var(--pf-global--BorderColor--200);
`;
class Sort extends React.Component {
constructor(props) {
super(props);
function Sort({ columns, qsConfig, onSort, i18n }) {
const location = useLocation();
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
let sortKey;
let sortOrder;
let isNumeric;
let sortKey;
let sortOrder;
let isNumeric;
const { qsConfig, location } = this.props;
const queryParams = parseQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
sortKey = queryParams.order_by.substr(1);
sortOrder = 'descending';
} else if (queryParams.order_by) {
sortKey = queryParams.order_by;
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);
const queryParams = parseQueryString(qsConfig, location.search);
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
sortKey = queryParams.order_by.substr(1);
sortOrder = 'descending';
} else if (queryParams.order_by) {
sortKey = queryParams.order_by;
sortOrder = 'ascending';
}
handleDropdownToggle(isSortDropdownOpen) {
this.setState({ isSortDropdownOpen });
if (qsConfig.integerFields.find(field => field === sortKey)) {
isNumeric = true;
} else {
isNumeric = false;
}
handleDropdownSelect({ target }) {
const { columns, onSort, qsConfig } = this.props;
const { sortOrder } = this.state;
const handleDropdownToggle = isOpen => {
setIsSortDropdownOpen(isOpen);
};
const handleDropdownSelect = ({ target }) => {
const { innerText } = target;
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText);
let isNumeric;
if (qsConfig.integerFields.find(field => field === sortKey)) {
const [{ key }] = columns.filter(({ name }) => name === innerText);
sortKey = key;
if (qsConfig.integerFields.find(field => field === key)) {
isNumeric = true;
} else {
isNumeric = false;
}
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric });
setIsSortDropdownOpen(false);
onSort(sortKey, sortOrder);
}
};
handleSort() {
const { onSort } = this.props;
const { sortKey, sortOrder } = this.state;
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
this.setState({ sortOrder: newSortOrder });
onSort(sortKey, newSortOrder);
}
const handleSort = () => {
onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending');
};
render() {
const { up } = DropdownPosition;
const { columns, i18n } = this.props;
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
const { up } = DropdownPosition;
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
if (!defaultSortedColumn) {
throw new Error(
'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>
if (!defaultSortedColumn) {
throw new Error(
'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={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 = {

View File

@ -1,5 +1,10 @@
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';
describe('<Sort />', () => {
@ -105,7 +110,7 @@ describe('<Sort />', () => {
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 = {
namespace: 'item',
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
@ -131,44 +136,18 @@ describe('<Sort />', () => {
const wrapper = mountWithContexts(
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
).find('Sort');
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } });
);
act(() => wrapper.find('Dropdown').invoke('onToggle')(true));
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');
});
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', () => {
const forwardNumericIconSelector = 'SortNumericDownIcon';
const reverseNumericIconSelector = 'SortNumericDownAltIcon';

View File

@ -1,5 +1,6 @@
import { t } from '@lingui/macro';
import ActivityStream from './screens/ActivityStream';
import Applications from './screens/Application';
import Credentials from './screens/Credential';
import CredentialTypes from './screens/CredentialType';
@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
path: '/schedules',
screen: Schedules,
},
{
title: i18n._(t`Activity Stream`),
path: '/activity_stream',
screen: ActivityStream,
},
{
title: i18n._(t`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 ApplicationAdd from './ApplicationAdd';
import Application from './Application';
import Breadcrumbs from '../../components/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader';
import { Detail, DetailList } from '../../components/DetailList';
const ApplicationAlert = styled(Alert)`
@ -45,7 +45,10 @@ function Applications({ i18n }) {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader
streamType="o_auth2_application,o_auth2_access_token"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch>
<Route path="/applications/add">
<ApplicationAdd

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
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 { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@ -13,9 +13,14 @@ import PaginatedDataList, {
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import {
encodeQueryString,
getQSConfig,
parseQueryString,
} from '../../../util/qs';
import HostListItem from './HostListItem';
import SmartInventoryButton from './SmartInventoryButton';
const QS_CONFIG = getQSConfig('host', {
page: 1,
@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
});
function HostList({ i18n }) {
const history = useHistory();
const location = useLocation();
const match = useRouteMatch();
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 {
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 =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@ -157,6 +182,14 @@ function HostList({ i18n }) {
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)}
/>,
...(canAdd
? [
<SmartInventoryButton
isDisabled={!hasNonDefaultSearchParams}
onClick={() => handleSmartInventoryClick()}
/>,
]
: []),
]}
/>
)}

View File

@ -1,5 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '../../../api';
import {
mountWithContexts,
@ -257,7 +258,7 @@ describe('<HostList />', () => {
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;
await act(async () => {
wrapper = mountWithContexts(<HostList />);
@ -265,9 +266,10 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper);
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({
data: {
actions: {
@ -282,5 +284,44 @@ describe('<HostList />', () => {
await waitForLoaded(wrapper);
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 { Config } from '../../contexts/Config';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import HostList from './HostList';
import HostAdd from './HostAdd';
@ -37,7 +37,7 @@ function Hosts({ i18n }) {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader streamType="host" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/hosts/add">
<HostAdd />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
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 () => {
let wrapper;
InventoriesAPI.readOptions.mockImplementationOnce(() =>

View File

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

View File

@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { I18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import {
@ -518,7 +518,7 @@ class JobOutput extends Component {
}
render() {
const { job, i18n } = this.props;
const { job } = this.props;
const {
contentError,
@ -596,15 +596,21 @@ class JobOutput extends Component {
</OutputWrapper>
</CardBody>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)}
label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
<>
<I18n>
{({ i18n }) => (
<AlertModal
isOpen={deletionError}
variant="danger"
onClose={() => this.setState({ deletionError: null })}
title={i18n._(t`Job Delete Error`)}
label={i18n._(t`Job Delete Error`)}
>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</I18n>
</>
)}
</Fragment>
);
@ -612,4 +618,4 @@ class JobOutput extends Component {
}
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 { UnifiedJobsAPI } from '../../api';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
const NOT_FOUND = 'not found';
@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
);
}
if (isLoading || !job?.id) {
// TODO show loading state
return <div>Loading...</div>;
return (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
}
const type = JOB_TYPE_URL_SEGMENTS[job.type];
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 { t } from '@lingui/macro';
import { PageSection } from '@patternfly/react-core';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import Job from './Job';
import JobTypeRedirect from './JobTypeRedirect';
import JobList from '../../components/JobList';
@ -40,7 +40,7 @@ function Jobs({ i18n }) {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader streamType="job" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route exact path={match.path}>
<PageSection>

View File

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

View File

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

View File

@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import ManagementJobs from './ManagementJobs';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<ManagementJobs />', () => {
let pageWrapper;
@ -17,6 +21,6 @@ describe('<ManagementJobs />', () => {
test('initially renders without crashing', () => {
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 NotificationTemplateAdd from './NotificationTemplateAdd';
import NotificationTemplate from './NotificationTemplate';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
function NotificationTemplates({ i18n }) {
const match = useRouteMatch();
@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader
streamType="notification_template"
breadcrumbConfig={breadcrumbConfig}
/>
<Switch>
<Route path={`${match.url}/add`}>
<NotificationTemplateAdd />

View File

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

View File

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

View File

@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import Organizations from './Organizations';
jest.mock('../../api');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
}));
describe('<Organizations />', () => {
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 { t } from '@lingui/macro';
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
import ProjectsList from './ProjectList/ProjectList';
import ProjectAdd from './ProjectAdd/ProjectAdd';
@ -45,7 +45,7 @@ function Projects({ i18n }) {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader streamType="project" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/projects/add">
<ProjectAdd />

View File

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

View File

@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) {
const { summary_fields = {} } = project;
const [contentError, setContentError] = useState(null);
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 [credentials, setCredentials] = useState({
scm: { typeId: null, value: null },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,113 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } 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 (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/radius/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && radius && (
<Formik initialValues={initialValues(radius)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="RADIUS_SERVER"
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>
);
}
export default withI18n()(RADIUSEdit);
export default RADIUSEdit;

View File

@ -1,16 +1,149 @@
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';
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 />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<RADIUSEdit />);
});
let history;
afterEach(() => {
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', () => {
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 ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import Breadcrumbs from '../../components/Breadcrumbs';
import ScreenHeader from '../../components/ScreenHeader';
import ActivityStream from './ActivityStream';
import AzureAD from './AzureAD';
import GitHub from './GitHub';
@ -129,7 +129,7 @@ function Settings({ i18n }) {
return (
<SettingsProvider value={result}>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<ScreenHeader streamType="setting" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/settings/activity_stream">
<ActivityStream />

View File

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

View File

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

View File

@ -1,25 +1,130 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import { CardBody, CardActionsRow } from '../../../../components/Card';
import React, { useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { Formik } from 'formik';
import { Form } from '@patternfly/react-core';
import { CardBody } 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 (
<CardBody>
{i18n._(t`Edit form coming soon :)`)}
<CardActionsRow>
<Button
aria-label={i18n._(t`Cancel`)}
component={Link}
to="/settings/tacacs/details"
>
{i18n._(t`Cancel`)}
</Button>
</CardActionsRow>
{isLoading && <ContentLoading />}
{!isLoading && error && <ContentError error={error} />}
{!isLoading && tacacs && (
<Formik initialValues={initialValues(tacacs)} onSubmit={handleSubmit}>
{formik => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<InputField
name="TACACSPLUS_HOST"
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>
);
}
export default withI18n()(TACACSEdit);
export default TACACSEdit;

View File

@ -1,16 +1,166 @@
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';
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 />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(<TACACSEdit />);
});
let history;
afterEach(() => {
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', () => {
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;
`;
class TeamListItem extends React.Component {
static propTypes = {
function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) {
TeamListItem.propTypes = {
team: Team.isRequired,
detailUrl: string.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
render() {
const { team, isSelected, onSelect, detailUrl, i18n } = this.props;
const labelId = `check-action-${team.id}`;
const labelId = `check-action-${team.id}`;
return (
<DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-team-${team.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link id={labelId} to={`${detailUrl}`}>
<b>{team.name}</b>
</Link>
</DataListCell>,
<DataListCell key="organization">
{team.summary_fields.organization && (
<Fragment>
<b>{i18n._(t`Organization`)}</b>{' '}
<Link
to={`/organizations/${team.summary_fields.organization.id}/details`}
>
<b>{team.summary_fields.organization.name}</b>
</Link>
</Fragment>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{team.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Team`)} position="top">
<Button
aria-label={i18n._(t`Edit Team`)}
variant="plain"
component={Link}
to={`/teams/${team.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
return (
<DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-team-${team.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="name">
<Link id={labelId} to={`${detailUrl}`}>
<b>{team.name}</b>
</Link>
</DataListCell>,
<DataListCell key="organization">
{team.summary_fields.organization && (
<Fragment>
<b>{i18n._(t`Organization`)}</b>{' '}
<Link
to={`/organizations/${team.summary_fields.organization.id}/details`}
>
<b>{team.summary_fields.organization.name}</b>
</Link>
</Fragment>
)}
</DataListCell>,
]}
/>
<DataListAction
aria-label="actions"
aria-labelledby={labelId}
id={labelId}
>
{team.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Team`)} position="top">
<Button
aria-label={i18n._(t`Edit Team`)}
variant="plain"
component={Link}
to={`/teams/${team.id}/edit`}
>
<PencilAltIcon />
</Button>
</Tooltip>
) : (
''
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
);
}
export default withI18n()(TeamListItem);

View File

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

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