mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03:30
commit
ac280e1446
24
CHANGELOG.md
24
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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*
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
10
awx/ui_next/src/api/models/ActivityStream.js
Normal file
10
awx/ui_next/src/api/models/ActivityStream.js
Normal 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;
|
||||
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
@ -1 +0,0 @@
|
||||
export { default } from './Breadcrumbs';
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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`));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -35,9 +35,9 @@ export default function usePreviewStep(
|
||||
}
|
||||
: null,
|
||||
initialValues: {},
|
||||
validate: () => ({}),
|
||||
isReady: true,
|
||||
error: null,
|
||||
setTouched: () => {},
|
||||
validate: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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}`
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
||||
setDeletionRole(role);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
|
||||
135
awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
Normal file
135
awx/ui_next/src/components/ScreenHeader/ScreenHeader.jsx
Normal 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);
|
||||
@ -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>
|
||||
);
|
||||
|
||||
1
awx/ui_next/src/components/ScreenHeader/index.js
Normal file
1
awx/ui_next/src/components/ScreenHeader/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ScreenHeader';
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
269
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal file
269
awx/ui_next/src/screens/ActivityStream/ActivityStream.jsx
Normal 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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
1
awx/ui_next/src/screens/ActivityStream/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ActivityStream';
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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 />
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -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(() =>
|
||||
|
||||
@ -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 />);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`} />;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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`),
|
||||
}}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user