mirror of
https://github.com/ansible/awx.git
synced 2026-04-09 03:59:21 -02:30
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>`.
|
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
||||||
|
|
||||||
|
# 17.0.0 (January 22, 2021)
|
||||||
|
- AWX now requires PostgreSQL 12 by default: https://github.com/ansible/awx/pull/8943
|
||||||
|
**Note:** users who encounter permissions errors at upgrade time should `chown -R ~/.awx/pgdocker` to ensure it's owned by the user running the install playbook
|
||||||
|
- Added support for region name for OpenStack inventory: https://github.com/ansible/awx/issues/5080
|
||||||
|
- Added the ability to chain undefined attributes in custom notification templates: https://github.com/ansible/awx/issues/8677
|
||||||
|
- Dramatically simplified the `image_build` role: https://github.com/ansible/awx/pull/8980
|
||||||
|
- Fixed a bug which can cause schema migrations to fail at install time: https://github.com/ansible/awx/issues/9077
|
||||||
|
- Fixed a bug which caused the `is_superuser` user property to be out of date in certain circumstances: https://github.com/ansible/awx/pull/8833
|
||||||
|
- Fixed a bug which sometimes results in race conditions on setting access: https://github.com/ansible/awx/pull/8580
|
||||||
|
- Fixed a bug which sometimes causes an unexpected delay in stdout for some playbooks: https://github.com/ansible/awx/issues/9085
|
||||||
|
- (UI) Added support for credential password prompting on job launch: https://github.com/ansible/awx/pull/9028
|
||||||
|
- (UI) Added the ability to configure LDAP settings in the UI: https://github.com/ansible/awx/issues/8291
|
||||||
|
- (UI) Added a sync button to the Project detail view: https://github.com/ansible/awx/issues/8847
|
||||||
|
- (UI) Added a form for configuring Google Outh 2.0 settings: https://github.com/ansible/awx/pull/8762
|
||||||
|
- (UI) Added searchable keys and related keys to the Credentials list: https://github.com/ansible/awx/issues/8603
|
||||||
|
- (UI) Added support for advanced search and copying to Notification Templates: https://github.com/ansible/awx/issues/7879
|
||||||
|
- (UI) Added support for prompting on workflow nodes: https://github.com/ansible/awx/issues/5913
|
||||||
|
- (UI) Added support for session timeouts: https://github.com/ansible/awx/pull/8250
|
||||||
|
- (UI) Fixed a bug that broke websocket streaming for the insecure ws:// protocol: https://github.com/ansible/awx/pull/8877
|
||||||
|
- (UI) Fixed a bug in the user interface when a translation for the browser's preferred locale isn't available: https://github.com/ansible/awx/issues/8884
|
||||||
|
- (UI) Fixed bug where navigating from one survey question form directly to another wasn't reloading the form: https://github.com/ansible/awx/issues/7522
|
||||||
|
- (UI) Fixed a bug which can cause an uncaught error while launching a Job Template: https://github.com/ansible/awx/issues/8936
|
||||||
|
- Updated autobahn to address CVE-2020-35678
|
||||||
|
|
||||||
## 16.0.0 (December 10, 2020)
|
## 16.0.0 (December 10, 2020)
|
||||||
- AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo
|
- AWX now ships with a reimagined user interface. **Please read this before upgrading:** https://groups.google.com/g/awx-project/c/KuT5Ao92HWo
|
||||||
- Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2
|
- Removed support for syncing inventory from Red Hat CloudForms - https://github.com/ansible/awx/commit/0b701b3b2
|
||||||
|
|||||||
@@ -497,7 +497,7 @@ Before starting the install process, review the [inventory](./installer/inventor
|
|||||||
|
|
||||||
*docker_compose_dir*
|
*docker_compose_dir*
|
||||||
|
|
||||||
> When using docker-compose, the `docker-compose.yml` file will be created there (default `/tmp/awxcompose`).
|
> When using docker-compose, the `docker-compose.yml` file will be created there (default `~/.awx/awxcompose`).
|
||||||
|
|
||||||
*custom_venv_dir*
|
*custom_venv_dir*
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
|
|
||||||
MAX_RETRIES = 2
|
MAX_RETRIES = 2
|
||||||
last_stats = time.time()
|
last_stats = time.time()
|
||||||
|
last_flush = time.time()
|
||||||
total = 0
|
total = 0
|
||||||
last_event = ''
|
last_event = ''
|
||||||
prof = None
|
prof = None
|
||||||
@@ -52,7 +53,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
|
|
||||||
def read(self, queue):
|
def read(self, queue):
|
||||||
try:
|
try:
|
||||||
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=settings.JOB_EVENT_BUFFER_SECONDS)
|
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1)
|
||||||
if res is None:
|
if res is None:
|
||||||
return {'event': 'FLUSH'}
|
return {'event': 'FLUSH'}
|
||||||
self.total += 1
|
self.total += 1
|
||||||
@@ -102,6 +103,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
now = tz_now()
|
now = tz_now()
|
||||||
if (
|
if (
|
||||||
force or
|
force or
|
||||||
|
(time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or
|
||||||
any([len(events) >= 1000 for events in self.buff.values()])
|
any([len(events) >= 1000 for events in self.buff.values()])
|
||||||
):
|
):
|
||||||
for cls, events in self.buff.items():
|
for cls, events in self.buff.items():
|
||||||
@@ -124,6 +126,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
for e in events:
|
for e in events:
|
||||||
emit_event_detail(e)
|
emit_event_detail(e)
|
||||||
self.buff = {}
|
self.buff = {}
|
||||||
|
self.last_flush = time.time()
|
||||||
|
|
||||||
def perform_work(self, body):
|
def perform_work(self, body):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -196,9 +196,9 @@ LOCAL_STDOUT_EXPIRE_TIME = 2592000
|
|||||||
# events into the database
|
# events into the database
|
||||||
JOB_EVENT_WORKERS = 4
|
JOB_EVENT_WORKERS = 4
|
||||||
|
|
||||||
# The number of seconds (must be an integer) to buffer callback receiver bulk
|
# The number of seconds to buffer callback receiver bulk
|
||||||
# writes in memory before flushing via JobEvent.objects.bulk_create()
|
# writes in memory before flushing via JobEvent.objects.bulk_create()
|
||||||
JOB_EVENT_BUFFER_SECONDS = 1
|
JOB_EVENT_BUFFER_SECONDS = .1
|
||||||
|
|
||||||
# The interval at which callback receiver statistics should be
|
# The interval at which callback receiver statistics should be
|
||||||
# recorded
|
# recorded
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ActivityStream from './models/ActivityStream';
|
||||||
import AdHocCommands from './models/AdHocCommands';
|
import AdHocCommands from './models/AdHocCommands';
|
||||||
import Applications from './models/Applications';
|
import Applications from './models/Applications';
|
||||||
import Auth from './models/Auth';
|
import Auth from './models/Auth';
|
||||||
@@ -39,6 +40,7 @@ import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
|
|||||||
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
|
||||||
import WorkflowJobs from './models/WorkflowJobs';
|
import WorkflowJobs from './models/WorkflowJobs';
|
||||||
|
|
||||||
|
const ActivityStreamAPI = new ActivityStream();
|
||||||
const AdHocCommandsAPI = new AdHocCommands();
|
const AdHocCommandsAPI = new AdHocCommands();
|
||||||
const ApplicationsAPI = new Applications();
|
const ApplicationsAPI = new Applications();
|
||||||
const AuthAPI = new Auth();
|
const AuthAPI = new Auth();
|
||||||
@@ -81,6 +83,7 @@ const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
|
|||||||
const WorkflowJobsAPI = new WorkflowJobs();
|
const WorkflowJobsAPI = new WorkflowJobs();
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
ActivityStreamAPI,
|
||||||
AdHocCommandsAPI,
|
AdHocCommandsAPI,
|
||||||
ApplicationsAPI,
|
ApplicationsAPI,
|
||||||
AuthAPI,
|
AuthAPI,
|
||||||
|
|||||||
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 PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -17,95 +17,57 @@ const readTeams = async queryParams => TeamsAPI.read(queryParams);
|
|||||||
|
|
||||||
const readTeamsOptions = async () => TeamsAPI.readOptions();
|
const readTeamsOptions = async () => TeamsAPI.readOptions();
|
||||||
|
|
||||||
class AddResourceRole extends React.Component {
|
function AddResourceRole({ onSave, onClose, roles, i18n, resource }) {
|
||||||
constructor(props) {
|
const [selectedResource, setSelectedResource] = useState(null);
|
||||||
super(props);
|
const [selectedResourceRows, setSelectedResourceRows] = useState([]);
|
||||||
|
const [selectedRoleRows, setSelectedRoleRows] = useState([]);
|
||||||
this.state = {
|
const [currentStepId, setCurrentStepId] = useState(1);
|
||||||
selectedResource: null,
|
const [maxEnabledStep, setMaxEnabledStep] = useState(1);
|
||||||
selectedResourceRows: [],
|
|
||||||
selectedRoleRows: [],
|
|
||||||
currentStepId: 1,
|
|
||||||
maxEnabledStep: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleResourceCheckboxClick = this.handleResourceCheckboxClick.bind(
|
|
||||||
this
|
|
||||||
);
|
|
||||||
this.handleResourceSelect = this.handleResourceSelect.bind(this);
|
|
||||||
this.handleRoleCheckboxClick = this.handleRoleCheckboxClick.bind(this);
|
|
||||||
this.handleWizardNext = this.handleWizardNext.bind(this);
|
|
||||||
this.handleWizardSave = this.handleWizardSave.bind(this);
|
|
||||||
this.handleWizardGoToStep = this.handleWizardGoToStep.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResourceCheckboxClick(user) {
|
|
||||||
const { selectedResourceRows, currentStepId } = this.state;
|
|
||||||
|
|
||||||
|
const handleResourceCheckboxClick = user => {
|
||||||
const selectedIndex = selectedResourceRows.findIndex(
|
const selectedIndex = selectedResourceRows.findIndex(
|
||||||
selectedRow => selectedRow.id === user.id
|
selectedRow => selectedRow.id === user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedIndex > -1) {
|
if (selectedIndex > -1) {
|
||||||
selectedResourceRows.splice(selectedIndex, 1);
|
selectedResourceRows.splice(selectedIndex, 1);
|
||||||
const stateToUpdate = { selectedResourceRows };
|
|
||||||
if (selectedResourceRows.length === 0) {
|
if (selectedResourceRows.length === 0) {
|
||||||
stateToUpdate.maxEnabledStep = currentStepId;
|
setMaxEnabledStep(currentStepId);
|
||||||
}
|
}
|
||||||
this.setState(stateToUpdate);
|
setSelectedRoleRows(selectedResourceRows);
|
||||||
} else {
|
} else {
|
||||||
this.setState(prevState => ({
|
setSelectedResourceRows([...selectedResourceRows, user]);
|
||||||
selectedResourceRows: [...prevState.selectedResourceRows, user],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
handleRoleCheckboxClick(role) {
|
|
||||||
const { selectedRoleRows } = this.state;
|
|
||||||
|
|
||||||
|
const handleRoleCheckboxClick = role => {
|
||||||
const selectedIndex = selectedRoleRows.findIndex(
|
const selectedIndex = selectedRoleRows.findIndex(
|
||||||
selectedRow => selectedRow.id === role.id
|
selectedRow => selectedRow.id === role.id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedIndex > -1) {
|
if (selectedIndex > -1) {
|
||||||
selectedRoleRows.splice(selectedIndex, 1);
|
selectedRoleRows.splice(selectedIndex, 1);
|
||||||
this.setState({ selectedRoleRows });
|
setSelectedRoleRows(selectedRoleRows);
|
||||||
} else {
|
} else {
|
||||||
this.setState(prevState => ({
|
setSelectedRoleRows([...selectedRoleRows, role]);
|
||||||
selectedRoleRows: [...prevState.selectedRoleRows, role],
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
handleResourceSelect(resourceType) {
|
const handleResourceSelect = resourceType => {
|
||||||
this.setState({
|
setSelectedResource(resourceType);
|
||||||
selectedResource: resourceType,
|
setSelectedResourceRows([]);
|
||||||
selectedResourceRows: [],
|
setSelectedRoleRows([]);
|
||||||
selectedRoleRows: [],
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWizardNext(step) {
|
const handleWizardNext = step => {
|
||||||
this.setState({
|
setCurrentStepId(step.id);
|
||||||
currentStepId: step.id,
|
setMaxEnabledStep(step.id);
|
||||||
maxEnabledStep: step.id,
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleWizardGoToStep(step) {
|
const handleWizardGoToStep = step => {
|
||||||
this.setState({
|
setCurrentStepId(step.id);
|
||||||
currentStepId: step.id,
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleWizardSave() {
|
|
||||||
const { onSave } = this.props;
|
|
||||||
const {
|
|
||||||
selectedResourceRows,
|
|
||||||
selectedRoleRows,
|
|
||||||
selectedResource,
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
|
const handleWizardSave = async () => {
|
||||||
try {
|
try {
|
||||||
const roleRequests = [];
|
const roleRequests = [];
|
||||||
|
|
||||||
@@ -134,201 +96,186 @@ class AddResourceRole extends React.Component {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// TODO: handle this error
|
// TODO: handle this error
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Object roles can be user only, so we remove them when
|
||||||
|
// showing role choices for team access
|
||||||
|
const selectableRoles = { ...roles };
|
||||||
|
if (selectedResource === 'teams') {
|
||||||
|
Object.keys(roles).forEach(key => {
|
||||||
|
if (selectableRoles[key].user_only) {
|
||||||
|
delete selectableRoles[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
const userSearchColumns = [
|
||||||
const {
|
{
|
||||||
selectedResource,
|
name: i18n._(t`Username`),
|
||||||
selectedResourceRows,
|
key: 'username__icontains',
|
||||||
selectedRoleRows,
|
isDefault: true,
|
||||||
currentStepId,
|
},
|
||||||
maxEnabledStep,
|
{
|
||||||
} = this.state;
|
name: i18n._(t`First Name`),
|
||||||
const { onClose, roles, i18n, resource } = this.props;
|
key: 'first_name__icontains',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Last Name`),
|
||||||
|
key: 'last_name__icontains',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const userSortColumns = [
|
||||||
|
{
|
||||||
|
name: i18n._(t`Username`),
|
||||||
|
key: 'username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`First Name`),
|
||||||
|
key: 'first_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Last Name`),
|
||||||
|
key: 'last_name',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const teamSearchColumns = [
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created By (Username)`),
|
||||||
|
key: 'created_by__username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Modified By (Username)`),
|
||||||
|
key: 'modified_by__username',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Object roles can be user only, so we remove them when
|
const teamSortColumns = [
|
||||||
// showing role choices for team access
|
{
|
||||||
const selectableRoles = { ...roles };
|
name: i18n._(t`Name`),
|
||||||
if (selectedResource === 'teams') {
|
key: 'name',
|
||||||
Object.keys(roles).forEach(key => {
|
},
|
||||||
if (selectableRoles[key].user_only) {
|
];
|
||||||
delete selectableRoles[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const userSearchColumns = [
|
let wizardTitle = '';
|
||||||
{
|
|
||||||
name: i18n._(t`Username`),
|
|
||||||
key: 'username__icontains',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`First Name`),
|
|
||||||
key: 'first_name__icontains',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Last Name`),
|
|
||||||
key: 'last_name__icontains',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const userSortColumns = [
|
switch (selectedResource) {
|
||||||
{
|
case 'users':
|
||||||
name: i18n._(t`Username`),
|
wizardTitle = i18n._(t`Add User Roles`);
|
||||||
key: 'username',
|
break;
|
||||||
},
|
case 'teams':
|
||||||
{
|
wizardTitle = i18n._(t`Add Team Roles`);
|
||||||
name: i18n._(t`First Name`),
|
break;
|
||||||
key: 'first_name',
|
default:
|
||||||
},
|
wizardTitle = i18n._(t`Add Roles`);
|
||||||
{
|
}
|
||||||
name: i18n._(t`Last Name`),
|
|
||||||
key: 'last_name',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const teamSearchColumns = [
|
const steps = [
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
id: 1,
|
||||||
key: 'name',
|
name: i18n._(t`Select a Resource Type`),
|
||||||
isDefault: true,
|
component: (
|
||||||
},
|
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
{
|
<div style={{ width: '100%', marginBottom: '10px' }}>
|
||||||
name: i18n._(t`Created By (Username)`),
|
{i18n._(
|
||||||
key: 'created_by__username',
|
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Modified By (Username)`),
|
|
||||||
key: 'modified_by__username',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const teamSortColumns = [
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let wizardTitle = '';
|
|
||||||
|
|
||||||
switch (selectedResource) {
|
|
||||||
case 'users':
|
|
||||||
wizardTitle = i18n._(t`Add User Roles`);
|
|
||||||
break;
|
|
||||||
case 'teams':
|
|
||||||
wizardTitle = i18n._(t`Add Team Roles`);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
wizardTitle = i18n._(t`Add Roles`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: i18n._(t`Select a Resource Type`),
|
|
||||||
component: (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ width: '100%', marginBottom: '10px' }}>
|
|
||||||
{i18n._(
|
|
||||||
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SelectableCard
|
|
||||||
isSelected={selectedResource === 'users'}
|
|
||||||
label={i18n._(t`Users`)}
|
|
||||||
dataCy="add-role-users"
|
|
||||||
ariaLabel={i18n._(t`Users`)}
|
|
||||||
onClick={() => this.handleResourceSelect('users')}
|
|
||||||
/>
|
|
||||||
{resource?.type === 'credential' &&
|
|
||||||
!resource?.organization ? null : (
|
|
||||||
<SelectableCard
|
|
||||||
isSelected={selectedResource === 'teams'}
|
|
||||||
label={i18n._(t`Teams`)}
|
|
||||||
dataCy="add-role-teams"
|
|
||||||
ariaLabel={i18n._(t`Teams`)}
|
|
||||||
onClick={() => this.handleResourceSelect('teams')}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
<SelectableCard
|
||||||
enableNext: selectedResource !== null,
|
isSelected={selectedResource === 'users'}
|
||||||
},
|
label={i18n._(t`Users`)}
|
||||||
{
|
ariaLabel={i18n._(t`Users`)}
|
||||||
id: 2,
|
dataCy="add-role-users"
|
||||||
name: i18n._(t`Select Items from List`),
|
onClick={() => handleResourceSelect('users')}
|
||||||
component: (
|
|
||||||
<Fragment>
|
|
||||||
{selectedResource === 'users' && (
|
|
||||||
<SelectResourceStep
|
|
||||||
searchColumns={userSearchColumns}
|
|
||||||
sortColumns={userSortColumns}
|
|
||||||
displayKey="username"
|
|
||||||
onRowClick={this.handleResourceCheckboxClick}
|
|
||||||
fetchItems={readUsers}
|
|
||||||
fetchOptions={readUsersOptions}
|
|
||||||
selectedLabel={i18n._(t`Selected`)}
|
|
||||||
selectedResourceRows={selectedResourceRows}
|
|
||||||
sortedColumnKey="username"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedResource === 'teams' && (
|
|
||||||
<SelectResourceStep
|
|
||||||
searchColumns={teamSearchColumns}
|
|
||||||
sortColumns={teamSortColumns}
|
|
||||||
onRowClick={this.handleResourceCheckboxClick}
|
|
||||||
fetchItems={readTeams}
|
|
||||||
fetchOptions={readTeamsOptions}
|
|
||||||
selectedLabel={i18n._(t`Selected`)}
|
|
||||||
selectedResourceRows={selectedResourceRows}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
),
|
|
||||||
enableNext: selectedResourceRows.length > 0,
|
|
||||||
canJumpTo: maxEnabledStep >= 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: i18n._(t`Select Roles to Apply`),
|
|
||||||
component: (
|
|
||||||
<SelectRoleStep
|
|
||||||
onRolesClick={this.handleRoleCheckboxClick}
|
|
||||||
roles={selectableRoles}
|
|
||||||
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
|
|
||||||
selectedListLabel={i18n._(t`Selected`)}
|
|
||||||
selectedResourceRows={selectedResourceRows}
|
|
||||||
selectedRoleRows={selectedRoleRows}
|
|
||||||
/>
|
/>
|
||||||
),
|
{resource?.type === 'credential' && !resource?.organization ? null : (
|
||||||
nextButtonText: i18n._(t`Save`),
|
<SelectableCard
|
||||||
enableNext: selectedRoleRows.length > 0,
|
isSelected={selectedResource === 'teams'}
|
||||||
canJumpTo: maxEnabledStep >= 3,
|
label={i18n._(t`Teams`)}
|
||||||
},
|
ariaLabel={i18n._(t`Teams`)}
|
||||||
];
|
dataCy="add-role-teams"
|
||||||
|
onClick={() => handleResourceSelect('teams')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableNext: selectedResource !== null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: i18n._(t`Select Items from List`),
|
||||||
|
component: (
|
||||||
|
<Fragment>
|
||||||
|
{selectedResource === 'users' && (
|
||||||
|
<SelectResourceStep
|
||||||
|
searchColumns={userSearchColumns}
|
||||||
|
sortColumns={userSortColumns}
|
||||||
|
displayKey="username"
|
||||||
|
onRowClick={handleResourceCheckboxClick}
|
||||||
|
fetchItems={readUsers}
|
||||||
|
fetchOptions={readUsersOptions}
|
||||||
|
selectedLabel={i18n._(t`Selected`)}
|
||||||
|
selectedResourceRows={selectedResourceRows}
|
||||||
|
sortedColumnKey="username"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedResource === 'teams' && (
|
||||||
|
<SelectResourceStep
|
||||||
|
searchColumns={teamSearchColumns}
|
||||||
|
sortColumns={teamSortColumns}
|
||||||
|
onRowClick={handleResourceCheckboxClick}
|
||||||
|
fetchItems={readTeams}
|
||||||
|
fetchOptions={readTeamsOptions}
|
||||||
|
selectedLabel={i18n._(t`Selected`)}
|
||||||
|
selectedResourceRows={selectedResourceRows}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
),
|
||||||
|
enableNext: selectedResourceRows.length > 0,
|
||||||
|
canJumpTo: maxEnabledStep >= 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: i18n._(t`Select Roles to Apply`),
|
||||||
|
component: (
|
||||||
|
<SelectRoleStep
|
||||||
|
onRolesClick={handleRoleCheckboxClick}
|
||||||
|
roles={selectableRoles}
|
||||||
|
selectedListKey={selectedResource === 'users' ? 'username' : 'name'}
|
||||||
|
selectedListLabel={i18n._(t`Selected`)}
|
||||||
|
selectedResourceRows={selectedResourceRows}
|
||||||
|
selectedRoleRows={selectedRoleRows}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
nextButtonText: i18n._(t`Save`),
|
||||||
|
enableNext: selectedRoleRows.length > 0,
|
||||||
|
canJumpTo: maxEnabledStep >= 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const currentStep = steps.find(step => step.id === currentStepId);
|
const currentStep = steps.find(step => step.id === currentStepId);
|
||||||
|
|
||||||
// TODO: somehow internationalize steps and currentStep.nextButtonText
|
// TODO: somehow internationalize steps and currentStep.nextButtonText
|
||||||
return (
|
return (
|
||||||
<Wizard
|
<Wizard
|
||||||
style={{ overflow: 'scroll' }}
|
style={{ overflow: 'scroll' }}
|
||||||
isOpen
|
isOpen
|
||||||
onNext={this.handleWizardNext}
|
onNext={handleWizardNext}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSave={this.handleWizardSave}
|
onSave={handleWizardSave}
|
||||||
onGoToStep={this.handleWizardGoToStep}
|
onGoToStep={step => handleWizardGoToStep(step)}
|
||||||
steps={steps}
|
steps={steps}
|
||||||
title={wizardTitle}
|
title={wizardTitle}
|
||||||
nextButtonText={currentStep.nextButtonText || undefined}
|
nextButtonText={currentStep.nextButtonText || undefined}
|
||||||
backButtonText={i18n._(t`Back`)}
|
backButtonText={i18n._(t`Back`)}
|
||||||
cancelButtonText={i18n._(t`Cancel`)}
|
cancelButtonText={i18n._(t`Cancel`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AddResourceRole.propTypes = {
|
AddResourceRole.propTypes = {
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
/* eslint-disable react/jsx-pascal-case */
|
/* eslint-disable react/jsx-pascal-case */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
|
import AddResourceRole, { _AddResourceRole } from './AddResourceRole';
|
||||||
import { TeamsAPI, UsersAPI } from '../../api';
|
import { TeamsAPI, UsersAPI } from '../../api';
|
||||||
|
|
||||||
jest.mock('../../api');
|
jest.mock('../../api/models/Teams');
|
||||||
|
jest.mock('../../api/models/Users');
|
||||||
|
|
||||||
|
// TODO: Once error handling is functional in
|
||||||
|
// this component write tests for it
|
||||||
|
|
||||||
describe('<_AddResourceRole />', () => {
|
describe('<_AddResourceRole />', () => {
|
||||||
UsersAPI.read.mockResolvedValue({
|
UsersAPI.read.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
count: 2,
|
count: 2,
|
||||||
results: [
|
results: [
|
||||||
{ id: 1, username: 'foo' },
|
{ id: 1, username: 'foo', url: '' },
|
||||||
{ id: 2, username: 'bar' },
|
{ id: 2, username: 'bar', url: '' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
UsersAPI.readOptions.mockResolvedValue({
|
||||||
|
data: { related: {}, actions: { GET: {} } },
|
||||||
|
});
|
||||||
|
TeamsAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Team foo', url: '' },
|
||||||
|
{ id: 2, name: 'Team bar', url: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
TeamsAPI.readOptions.mockResolvedValue({
|
||||||
|
data: { related: {}, actions: { GET: {} } },
|
||||||
|
});
|
||||||
const roles = {
|
const roles = {
|
||||||
admin_role: {
|
admin_role: {
|
||||||
description: 'Can manage all aspects of the organization',
|
description: 'Can manage all aspects of the organization',
|
||||||
@@ -39,191 +63,165 @@ describe('<_AddResourceRole />', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test('handleRoleCheckboxClick properly updates state', () => {
|
test('should save properly', async () => {
|
||||||
const wrapper = shallow(
|
let wrapper;
|
||||||
<_AddResourceRole
|
act(() => {
|
||||||
onClose={() => {}}
|
wrapper = mountWithContexts(
|
||||||
onSave={() => {}}
|
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||||
roles={roles}
|
{ context: { network: { handleHttpError: () => {} } } }
|
||||||
i18n={{ _: val => val.toString() }}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
wrapper.setState({
|
|
||||||
selectedRoleRows: [
|
|
||||||
{
|
|
||||||
description: 'Can manage all aspects of the organization',
|
|
||||||
name: 'Admin',
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
wrapper.instance().handleRoleCheckboxClick({
|
wrapper.update();
|
||||||
description: 'Can manage all aspects of the organization',
|
|
||||||
name: 'Admin',
|
// Step 1
|
||||||
id: 1,
|
|
||||||
});
|
|
||||||
expect(wrapper.state('selectedRoleRows')).toEqual([]);
|
|
||||||
wrapper.instance().handleRoleCheckboxClick({
|
|
||||||
description: 'Can manage all aspects of the organization',
|
|
||||||
name: 'Admin',
|
|
||||||
id: 1,
|
|
||||||
});
|
|
||||||
expect(wrapper.state('selectedRoleRows')).toEqual([
|
|
||||||
{
|
|
||||||
description: 'Can manage all aspects of the organization',
|
|
||||||
name: 'Admin',
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
test('handleResourceCheckboxClick properly updates state', () => {
|
|
||||||
const wrapper = shallow(
|
|
||||||
<_AddResourceRole
|
|
||||||
onClose={() => {}}
|
|
||||||
onSave={() => {}}
|
|
||||||
roles={roles}
|
|
||||||
i18n={{ _: val => val.toString() }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
wrapper.setState({
|
|
||||||
selectedResourceRows: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
username: 'foobar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
wrapper.instance().handleResourceCheckboxClick({
|
|
||||||
id: 1,
|
|
||||||
username: 'foobar',
|
|
||||||
});
|
|
||||||
expect(wrapper.state('selectedResourceRows')).toEqual([]);
|
|
||||||
wrapper.instance().handleResourceCheckboxClick({
|
|
||||||
id: 1,
|
|
||||||
username: 'foobar',
|
|
||||||
});
|
|
||||||
expect(wrapper.state('selectedResourceRows')).toEqual([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
username: 'foobar',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
test('clicking user/team cards updates state', () => {
|
|
||||||
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
|
||||||
{ context: { network: { handleHttpError: () => {} } } }
|
|
||||||
).find('AddResourceRole');
|
|
||||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||||
expect(selectableCardWrapper.length).toBe(2);
|
expect(selectableCardWrapper.length).toBe(2);
|
||||||
selectableCardWrapper.first().simulate('click');
|
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||||
expect(spy).toHaveBeenCalledWith('users');
|
wrapper.update();
|
||||||
expect(wrapper.state('selectedResource')).toBe('users');
|
await act(async () =>
|
||||||
selectableCardWrapper.at(1).simulate('click');
|
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||||
expect(spy).toHaveBeenCalledWith('teams');
|
|
||||||
expect(wrapper.state('selectedResource')).toBe('teams');
|
|
||||||
});
|
|
||||||
test('handleResourceSelect clears out selected lists and sets selectedResource', () => {
|
|
||||||
const wrapper = shallow(
|
|
||||||
<_AddResourceRole
|
|
||||||
onClose={() => {}}
|
|
||||||
onSave={() => {}}
|
|
||||||
roles={roles}
|
|
||||||
i18n={{ _: val => val.toString() }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
wrapper.setState({
|
wrapper.update();
|
||||||
selectedResource: 'teams',
|
|
||||||
selectedResourceRows: [
|
// Step 2
|
||||||
{
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
id: 1,
|
act(() =>
|
||||||
username: 'foobar',
|
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
|
||||||
},
|
);
|
||||||
],
|
wrapper.update();
|
||||||
selectedRoleRows: [
|
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
|
||||||
{
|
true
|
||||||
description: 'Can manage all aspects of the organization',
|
);
|
||||||
id: 1,
|
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||||
name: 'Admin',
|
wrapper.update();
|
||||||
},
|
|
||||||
],
|
// Step 3
|
||||||
});
|
act(() =>
|
||||||
wrapper.instance().handleResourceSelect('users');
|
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
|
||||||
expect(wrapper.state()).toEqual({
|
);
|
||||||
selectedResource: 'users',
|
wrapper.update();
|
||||||
selectedResourceRows: [],
|
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
|
||||||
selectedRoleRows: [],
|
true
|
||||||
currentStepId: 1,
|
);
|
||||||
maxEnabledStep: 1,
|
|
||||||
});
|
// Save
|
||||||
wrapper.instance().handleResourceSelect('teams');
|
await act(async () =>
|
||||||
expect(wrapper.state()).toEqual({
|
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||||
selectedResource: 'teams',
|
);
|
||||||
selectedResourceRows: [],
|
expect(UsersAPI.associateRole).toBeCalledWith(1, 1);
|
||||||
selectedRoleRows: [],
|
|
||||||
currentStepId: 1,
|
|
||||||
maxEnabledStep: 1,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
test('handleWizardSave makes correct api calls, calls onSave when done', async () => {
|
|
||||||
const handleSave = jest.fn();
|
test('should successfuly click user/team cards', async () => {
|
||||||
const wrapper = mountWithContexts(
|
let wrapper;
|
||||||
<AddResourceRole onClose={() => {}} onSave={handleSave} roles={roles} />,
|
act(() => {
|
||||||
{ context: { network: { handleHttpError: () => {} } } }
|
wrapper = mountWithContexts(
|
||||||
).find('AddResourceRole');
|
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||||
wrapper.setState({
|
{ context: { network: { handleHttpError: () => {} } } }
|
||||||
selectedResource: 'users',
|
);
|
||||||
selectedResourceRows: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
username: 'foobar',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
selectedRoleRows: [
|
|
||||||
{
|
|
||||||
description: 'Can manage all aspects of the organization',
|
|
||||||
id: 1,
|
|
||||||
name: 'Admin',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'May run any executable resources in the organization',
|
|
||||||
id: 2,
|
|
||||||
name: 'Execute',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
await wrapper.instance().handleWizardSave();
|
wrapper.update();
|
||||||
expect(UsersAPI.associateRole).toHaveBeenCalledTimes(2);
|
|
||||||
expect(handleSave).toHaveBeenCalled();
|
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||||
wrapper.setState({
|
expect(selectableCardWrapper.length).toBe(2);
|
||||||
selectedResource: 'teams',
|
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||||
selectedResourceRows: [
|
wrapper.update();
|
||||||
{
|
|
||||||
id: 1,
|
await waitForElement(
|
||||||
name: 'foobar',
|
wrapper,
|
||||||
},
|
'SelectableCard[label="Users"]',
|
||||||
],
|
el => el.prop('isSelected') === true
|
||||||
selectedRoleRows: [
|
);
|
||||||
{
|
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
|
||||||
description: 'Can manage all aspects of the organization',
|
wrapper.update();
|
||||||
id: 1,
|
|
||||||
name: 'Admin',
|
await waitForElement(
|
||||||
},
|
wrapper,
|
||||||
{
|
'SelectableCard[label="Teams"]',
|
||||||
description: 'May run any executable resources in the organization',
|
el => el.prop('isSelected') === true
|
||||||
id: 2,
|
);
|
||||||
name: 'Execute',
|
});
|
||||||
},
|
|
||||||
],
|
test('should reset values with resource type changes', async () => {
|
||||||
|
let wrapper;
|
||||||
|
act(() => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<AddResourceRole onClose={() => {}} onSave={() => {}} roles={roles} />,
|
||||||
|
{ context: { network: { handleHttpError: () => {} } } }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await wrapper.instance().handleWizardSave();
|
wrapper.update();
|
||||||
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
|
|
||||||
expect(handleSave).toHaveBeenCalled();
|
// Step 1
|
||||||
|
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||||
|
expect(selectableCardWrapper.length).toBe(2);
|
||||||
|
act(() => wrapper.find('SelectableCard[label="Users"]').prop('onClick')());
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
|
act(() =>
|
||||||
|
wrapper.find('DataListCheck[name="foo"]').invoke('onChange')(true)
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('DataListCheck[name="foo"]').prop('checked')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
act(() =>
|
||||||
|
wrapper.find('Checkbox[aria-label="Admin"]').invoke('onChange')(true)
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('Checkbox[aria-label="Admin"]').prop('isChecked')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Go back to step 1
|
||||||
|
act(() => {
|
||||||
|
wrapper
|
||||||
|
.find('WizardNavItem[content="Select a Resource Type"]')
|
||||||
|
.find('button')
|
||||||
|
.prop('onClick')({ id: 1 });
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('WizardNavItem[content="Select a Resource Type"]')
|
||||||
|
.prop('isCurrent')
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// Go back to step 1 and this time select teams. Doing so should clear following steps
|
||||||
|
act(() => wrapper.find('SelectableCard[label="Teams"]').prop('onClick')());
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[type="submit"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Make sure no teams have been selected
|
||||||
|
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||||
|
wrapper
|
||||||
|
.find('DataListCheck')
|
||||||
|
.map(item => expect(item.prop('checked')).toBe(false));
|
||||||
|
act(() => wrapper.find('Button[type="submit"]').prop('onClick')());
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
// Make sure that no roles have been selected
|
||||||
|
wrapper
|
||||||
|
.find('Checkbox')
|
||||||
|
.map(card => expect(card.prop('isChecked')).toBe(false));
|
||||||
|
|
||||||
|
// Make sure the save button is disabled
|
||||||
|
expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not display team as a choice in case credential does not have organization', () => {
|
test('should not display team as a choice in case credential does not have organization', () => {
|
||||||
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<AddResourceRole
|
<AddResourceRole
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
@@ -232,11 +230,13 @@ describe('<_AddResourceRole />', () => {
|
|||||||
resource={{ type: 'credential', organization: null }}
|
resource={{ type: 'credential', organization: null }}
|
||||||
/>,
|
/>,
|
||||||
{ context: { network: { handleHttpError: () => {} } } }
|
{ context: { network: { handleHttpError: () => {} } } }
|
||||||
).find('AddResourceRole');
|
);
|
||||||
const selectableCardWrapper = wrapper.find('SelectableCard');
|
|
||||||
expect(selectableCardWrapper.length).toBe(1);
|
expect(wrapper.find('SelectableCard').length).toBe(1);
|
||||||
selectableCardWrapper.first().simulate('click');
|
wrapper.find('SelectableCard[label="Users"]').simulate('click');
|
||||||
expect(spy).toHaveBeenCalledWith('users');
|
wrapper.update();
|
||||||
expect(wrapper.state('selectedResource')).toBe('users');
|
expect(
|
||||||
|
wrapper.find('SelectableCard[label="Users"]').prop('isSelected')
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,59 +7,55 @@ import { t } from '@lingui/macro';
|
|||||||
import CheckboxCard from './CheckboxCard';
|
import CheckboxCard from './CheckboxCard';
|
||||||
import SelectedList from '../SelectedList';
|
import SelectedList from '../SelectedList';
|
||||||
|
|
||||||
class RolesStep extends React.Component {
|
function RolesStep({
|
||||||
render() {
|
onRolesClick,
|
||||||
const {
|
roles,
|
||||||
onRolesClick,
|
selectedListKey,
|
||||||
roles,
|
selectedListLabel,
|
||||||
selectedListKey,
|
selectedResourceRows,
|
||||||
selectedListLabel,
|
selectedRoleRows,
|
||||||
selectedResourceRows,
|
i18n,
|
||||||
selectedRoleRows,
|
}) {
|
||||||
i18n,
|
return (
|
||||||
} = this.props;
|
<Fragment>
|
||||||
|
<div>
|
||||||
return (
|
{i18n._(
|
||||||
<Fragment>
|
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
|
||||||
<div>
|
)}
|
||||||
{i18n._(
|
</div>
|
||||||
t`Choose roles to apply to the selected resources. Note that all selected roles will be applied to all selected resources.`
|
<div>
|
||||||
)}
|
{selectedResourceRows.length > 0 && (
|
||||||
</div>
|
<SelectedList
|
||||||
<div>
|
displayKey={selectedListKey}
|
||||||
{selectedResourceRows.length > 0 && (
|
isReadOnly
|
||||||
<SelectedList
|
label={selectedListLabel || i18n._(t`Selected`)}
|
||||||
displayKey={selectedListKey}
|
selected={selectedResourceRows}
|
||||||
isReadOnly
|
/>
|
||||||
label={selectedListLabel || i18n._(t`Selected`)}
|
)}
|
||||||
selected={selectedResourceRows}
|
</div>
|
||||||
/>
|
<div
|
||||||
)}
|
style={{
|
||||||
</div>
|
display: 'grid',
|
||||||
<div
|
gridTemplateColumns: '1fr 1fr',
|
||||||
style={{
|
gap: '20px 20px',
|
||||||
display: 'grid',
|
marginTop: '20px',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
}}
|
||||||
gap: '20px 20px',
|
>
|
||||||
marginTop: '20px',
|
{Object.keys(roles).map(role => (
|
||||||
}}
|
<CheckboxCard
|
||||||
>
|
description={roles[role].description}
|
||||||
{Object.keys(roles).map(role => (
|
itemId={roles[role].id}
|
||||||
<CheckboxCard
|
isSelected={selectedRoleRows.some(
|
||||||
description={roles[role].description}
|
item => item.id === roles[role].id
|
||||||
itemId={roles[role].id}
|
)}
|
||||||
isSelected={selectedRoleRows.some(
|
key={roles[role].id}
|
||||||
item => item.id === roles[role].id
|
name={roles[role].name}
|
||||||
)}
|
onSelect={() => onRolesClick(roles[role])}
|
||||||
key={roles[role].id}
|
/>
|
||||||
name={roles[role].name}
|
))}
|
||||||
onSelect={() => onRolesClick(roles[role])}
|
</div>
|
||||||
/>
|
</Fragment>
|
||||||
))}
|
);
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RolesStep.propTypes = {
|
RolesStep.propTypes = {
|
||||||
|
|||||||
@@ -12,52 +12,44 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormSelect, FormSelectOption } from '@patternfly/react-core';
|
import { FormSelect, FormSelectOption } from '@patternfly/react-core';
|
||||||
|
|
||||||
class AnsibleSelect extends React.Component {
|
function AnsibleSelect({
|
||||||
constructor(props) {
|
id,
|
||||||
super(props);
|
data,
|
||||||
this.onSelectChange = this.onSelectChange.bind(this);
|
i18n,
|
||||||
}
|
isValid,
|
||||||
|
onBlur,
|
||||||
onSelectChange(val, event) {
|
value,
|
||||||
const { onChange, name } = this.props;
|
className,
|
||||||
|
isDisabled,
|
||||||
|
onChange,
|
||||||
|
name,
|
||||||
|
}) {
|
||||||
|
const onSelectChange = (val, event) => {
|
||||||
event.target.name = name;
|
event.target.name = name;
|
||||||
onChange(event, val);
|
onChange(event, val);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const {
|
<FormSelect
|
||||||
id,
|
id={id}
|
||||||
data,
|
value={value}
|
||||||
i18n,
|
onChange={onSelectChange}
|
||||||
isValid,
|
onBlur={onBlur}
|
||||||
onBlur,
|
aria-label={i18n._(t`Select Input`)}
|
||||||
value,
|
validated={isValid ? 'default' : 'error'}
|
||||||
className,
|
className={className}
|
||||||
isDisabled,
|
isDisabled={isDisabled}
|
||||||
} = this.props;
|
>
|
||||||
|
{data.map(option => (
|
||||||
return (
|
<FormSelectOption
|
||||||
<FormSelect
|
key={option.key}
|
||||||
id={id}
|
value={option.value}
|
||||||
value={value}
|
label={option.label}
|
||||||
onChange={this.onSelectChange}
|
isDisabled={option.isDisabled}
|
||||||
onBlur={onBlur}
|
/>
|
||||||
aria-label={i18n._(t`Select Input`)}
|
))}
|
||||||
validated={isValid ? 'default' : 'error'}
|
</FormSelect>
|
||||||
className={className}
|
);
|
||||||
isDisabled={isDisabled}
|
|
||||||
>
|
|
||||||
{data.map(option => (
|
|
||||||
<FormSelectOption
|
|
||||||
key={option.key}
|
|
||||||
value={option.value}
|
|
||||||
label={option.label}
|
|
||||||
isDisabled={option.isDisabled}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FormSelect>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Option = shape({
|
const Option = shape({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import AnsibleSelect, { _AnsibleSelect } from './AnsibleSelect';
|
import AnsibleSelect from './AnsibleSelect';
|
||||||
|
|
||||||
const mockData = [
|
const mockData = [
|
||||||
{
|
{
|
||||||
@@ -16,6 +16,7 @@ const mockData = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('<AnsibleSelect />', () => {
|
describe('<AnsibleSelect />', () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
test('initially renders succesfully', async () => {
|
test('initially renders succesfully', async () => {
|
||||||
mountWithContexts(
|
mountWithContexts(
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
@@ -29,19 +30,18 @@ describe('<AnsibleSelect />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('calls "onSelectChange" on dropdown select change', () => {
|
test('calls "onSelectChange" on dropdown select change', () => {
|
||||||
const spy = jest.spyOn(_AnsibleSelect.prototype, 'onSelectChange');
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
id="bar"
|
id="bar"
|
||||||
value="foo"
|
value="foo"
|
||||||
name="bar"
|
name="bar"
|
||||||
onChange={() => {}}
|
onChange={onChange}
|
||||||
data={mockData}
|
data={mockData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
wrapper.find('select').simulate('change');
|
wrapper.find('select').simulate('change');
|
||||||
expect(spy).toHaveBeenCalled();
|
expect(onChange).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Returns correct select options', () => {
|
test('Returns correct select options', () => {
|
||||||
|
|||||||
@@ -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
|
// TODO: Recommend renaming this component to avoid confusion
|
||||||
// with ExpandingContainer
|
// with ExpandingContainer
|
||||||
class ExpandCollapse extends React.Component {
|
function ExpandCollapse({ isCompact, onCompact, onExpand, i18n }) {
|
||||||
render() {
|
return (
|
||||||
const { isCompact, onCompact, onExpand, i18n } = this.props;
|
<Fragment>
|
||||||
|
<ToolbarItem>
|
||||||
return (
|
<Button
|
||||||
<Fragment>
|
variant="plain"
|
||||||
<ToolbarItem>
|
aria-label={i18n._(t`Collapse`)}
|
||||||
<Button
|
onClick={onCompact}
|
||||||
variant="plain"
|
isActive={isCompact}
|
||||||
aria-label={i18n._(t`Collapse`)}
|
>
|
||||||
onClick={onCompact}
|
<BarsIcon />
|
||||||
isActive={isCompact}
|
</Button>
|
||||||
>
|
</ToolbarItem>
|
||||||
<BarsIcon />
|
<ToolbarItem>
|
||||||
</Button>
|
<Button
|
||||||
</ToolbarItem>
|
variant="plain"
|
||||||
<ToolbarItem>
|
aria-label={i18n._(t`Expand`)}
|
||||||
<Button
|
onClick={onExpand}
|
||||||
variant="plain"
|
isActive={!isCompact}
|
||||||
aria-label={i18n._(t`Expand`)}
|
>
|
||||||
onClick={onExpand}
|
<EqualsIcon />
|
||||||
isActive={!isCompact}
|
</Button>
|
||||||
>
|
</ToolbarItem>
|
||||||
<EqualsIcon />
|
</Fragment>
|
||||||
</Button>
|
);
|
||||||
</ToolbarItem>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ExpandCollapse.propTypes = {
|
ExpandCollapse.propTypes = {
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
function PasswordInput(props) {
|
function PasswordInput(props) {
|
||||||
const { id, name, validate, isRequired, isDisabled, i18n } = props;
|
const {
|
||||||
|
autocomplete,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
validate,
|
||||||
|
isRequired,
|
||||||
|
isDisabled,
|
||||||
|
i18n,
|
||||||
|
} = props;
|
||||||
const [inputType, setInputType] = useState('password');
|
const [inputType, setInputType] = useState('password');
|
||||||
const [field, meta] = useField({ name, validate });
|
const [field, meta] = useField({ name, validate });
|
||||||
|
|
||||||
@@ -38,6 +46,7 @@ function PasswordInput(props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
autoComplete={autocomplete}
|
||||||
id={id}
|
id={id}
|
||||||
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
placeholder={field.value === '$encrypted$' ? 'ENCRYPTED' : undefined}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -55,6 +64,7 @@ function PasswordInput(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PasswordInput.propTypes = {
|
PasswordInput.propTypes = {
|
||||||
|
autocomplete: PropTypes.string,
|
||||||
id: PropTypes.string.isRequired,
|
id: PropTypes.string.isRequired,
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
validate: PropTypes.func,
|
validate: PropTypes.func,
|
||||||
@@ -63,6 +73,7 @@ PasswordInput.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
PasswordInput.defaultProps = {
|
PasswordInput.defaultProps = {
|
||||||
|
autocomplete: 'new-password',
|
||||||
validate: () => {},
|
validate: () => {},
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) {
|
|||||||
!launchData.ask_limit_on_launch &&
|
!launchData.ask_limit_on_launch &&
|
||||||
!launchData.ask_scm_branch_on_launch &&
|
!launchData.ask_scm_branch_on_launch &&
|
||||||
!launchData.survey_enabled &&
|
!launchData.survey_enabled &&
|
||||||
|
(!launchData.passwords_needed_to_start ||
|
||||||
|
launchData.passwords_needed_to_start.length === 0) &&
|
||||||
(!launchData.variables_needed_to_start ||
|
(!launchData.variables_needed_to_start ||
|
||||||
launchData.variables_needed_to_start.length === 0)
|
launchData.variables_needed_to_start.length === 0)
|
||||||
);
|
);
|
||||||
@@ -100,17 +102,20 @@ class LaunchButton extends React.Component {
|
|||||||
async launchWithParams(params) {
|
async launchWithParams(params) {
|
||||||
try {
|
try {
|
||||||
const { history, resource } = this.props;
|
const { history, resource } = this.props;
|
||||||
const jobPromise =
|
let jobPromise;
|
||||||
resource.type === 'workflow_job_template'
|
|
||||||
? WorkflowJobTemplatesAPI.launch(resource.id, params || {})
|
if (resource.type === 'job_template') {
|
||||||
: JobTemplatesAPI.launch(resource.id, params || {});
|
jobPromise = JobTemplatesAPI.launch(resource.id, params || {});
|
||||||
|
} else if (resource.type === 'workflow_job_template') {
|
||||||
|
jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {});
|
||||||
|
} else if (resource.type === 'job') {
|
||||||
|
jobPromise = JobsAPI.relaunch(resource.id, params || {});
|
||||||
|
} else if (resource.type === 'workflow_job') {
|
||||||
|
jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {});
|
||||||
|
}
|
||||||
|
|
||||||
const { data: job } = await jobPromise;
|
const { data: job } = await jobPromise;
|
||||||
history.push(
|
history.push(`/jobs/${job.id}/output`);
|
||||||
`/${
|
|
||||||
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
|
|
||||||
}/${job.id}/output`
|
|
||||||
);
|
|
||||||
} catch (launchError) {
|
} catch (launchError) {
|
||||||
this.setState({ launchError });
|
this.setState({ launchError });
|
||||||
}
|
}
|
||||||
@@ -127,20 +132,15 @@ class LaunchButton extends React.Component {
|
|||||||
readRelaunch = InventorySourcesAPI.readLaunchUpdate(
|
readRelaunch = InventorySourcesAPI.readLaunchUpdate(
|
||||||
resource.inventory_source
|
resource.inventory_source
|
||||||
);
|
);
|
||||||
relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source);
|
|
||||||
} else if (resource.type === 'project_update') {
|
} else if (resource.type === 'project_update') {
|
||||||
// We'll need to handle the scenario where the project no longer exists
|
// We'll need to handle the scenario where the project no longer exists
|
||||||
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
|
readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project);
|
||||||
relaunch = ProjectsAPI.launchUpdate(resource.project);
|
|
||||||
} else if (resource.type === 'workflow_job') {
|
} else if (resource.type === 'workflow_job') {
|
||||||
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
|
readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id);
|
||||||
relaunch = WorkflowJobsAPI.relaunch(resource.id);
|
|
||||||
} else if (resource.type === 'ad_hoc_command') {
|
} else if (resource.type === 'ad_hoc_command') {
|
||||||
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
|
readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id);
|
||||||
relaunch = AdHocCommandsAPI.relaunch(resource.id);
|
|
||||||
} else if (resource.type === 'job') {
|
} else if (resource.type === 'job') {
|
||||||
readRelaunch = JobsAPI.readRelaunch(resource.id);
|
readRelaunch = JobsAPI.readRelaunch(resource.id);
|
||||||
relaunch = JobsAPI.relaunch(resource.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -149,11 +149,22 @@ class LaunchButton extends React.Component {
|
|||||||
!relaunchConfig.passwords_needed_to_start ||
|
!relaunchConfig.passwords_needed_to_start ||
|
||||||
relaunchConfig.passwords_needed_to_start.length === 0
|
relaunchConfig.passwords_needed_to_start.length === 0
|
||||||
) {
|
) {
|
||||||
|
if (resource.type === 'inventory_update') {
|
||||||
|
relaunch = InventorySourcesAPI.launchUpdate(
|
||||||
|
resource.inventory_source
|
||||||
|
);
|
||||||
|
} else if (resource.type === 'project_update') {
|
||||||
|
relaunch = ProjectsAPI.launchUpdate(resource.project);
|
||||||
|
} else if (resource.type === 'workflow_job') {
|
||||||
|
relaunch = WorkflowJobsAPI.relaunch(resource.id);
|
||||||
|
} else if (resource.type === 'ad_hoc_command') {
|
||||||
|
relaunch = AdHocCommandsAPI.relaunch(resource.id);
|
||||||
|
} else if (resource.type === 'job') {
|
||||||
|
relaunch = JobsAPI.relaunch(resource.id);
|
||||||
|
}
|
||||||
const { data: job } = await relaunch;
|
const { data: job } = await relaunch;
|
||||||
history.push(`/jobs/${job.id}/output`);
|
history.push(`/jobs/${job.id}/output`);
|
||||||
} else {
|
} else {
|
||||||
// TODO: restructure (async?) to send launch command after prompts
|
|
||||||
// TODO: does relaunch need different prompt treatment than launch?
|
|
||||||
this.setState({
|
this.setState({
|
||||||
showLaunchPrompt: true,
|
showLaunchPrompt: true,
|
||||||
launchConfig: relaunchConfig,
|
launchConfig: relaunchConfig,
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
import { sleep } from '../../../testUtils/testUtils';
|
import { sleep } from '../../../testUtils/testUtils';
|
||||||
|
|
||||||
import LaunchButton from './LaunchButton';
|
import LaunchButton from './LaunchButton';
|
||||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
|
import {
|
||||||
|
InventorySourcesAPI,
|
||||||
|
JobsAPI,
|
||||||
|
JobTemplatesAPI,
|
||||||
|
ProjectsAPI,
|
||||||
|
WorkflowJobsAPI,
|
||||||
|
WorkflowJobTemplatesAPI,
|
||||||
|
} from '../../api';
|
||||||
|
|
||||||
jest.mock('../../api/models/WorkflowJobTemplates');
|
jest.mock('../../api');
|
||||||
jest.mock('../../api/models/JobTemplates');
|
|
||||||
|
|
||||||
describe('LaunchButton', () => {
|
describe('LaunchButton', () => {
|
||||||
JobTemplatesAPI.readLaunch.mockResolvedValue({
|
JobTemplatesAPI.readLaunch.mockResolvedValue({
|
||||||
@@ -22,10 +28,14 @@ describe('LaunchButton', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const children = ({ handleLaunch }) => (
|
const launchButton = ({ handleLaunch }) => (
|
||||||
<button type="submit" onClick={() => handleLaunch()} />
|
<button type="submit" onClick={() => handleLaunch()} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const relaunchButton = ({ handleRelaunch }) => (
|
||||||
|
<button type="submit" onClick={() => handleRelaunch()} />
|
||||||
|
);
|
||||||
|
|
||||||
const resource = {
|
const resource = {
|
||||||
id: 1,
|
id: 1,
|
||||||
type: 'job_template',
|
type: 'job_template',
|
||||||
@@ -35,7 +45,7 @@ describe('LaunchButton', () => {
|
|||||||
|
|
||||||
test('renders the expected content', () => {
|
test('renders the expected content', () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<LaunchButton resource={resource}>{children}</LaunchButton>
|
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
|
||||||
);
|
);
|
||||||
expect(wrapper).toHaveLength(1);
|
expect(wrapper).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -51,7 +61,7 @@ describe('LaunchButton', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<LaunchButton resource={resource}>{children}</LaunchButton>,
|
<LaunchButton resource={resource}>{launchButton}</LaunchButton>,
|
||||||
{
|
{
|
||||||
context: {
|
context: {
|
||||||
router: { history },
|
router: { history },
|
||||||
@@ -87,7 +97,7 @@ describe('LaunchButton', () => {
|
|||||||
type: 'workflow_job_template',
|
type: 'workflow_job_template',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{launchButton}
|
||||||
</LaunchButton>,
|
</LaunchButton>,
|
||||||
{
|
{
|
||||||
context: {
|
context: {
|
||||||
@@ -100,12 +110,162 @@ describe('LaunchButton', () => {
|
|||||||
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
|
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
|
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {});
|
||||||
expect(history.location.pathname).toEqual('/jobs/workflow/9000/output');
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should relaunch job correctly', async () => {
|
||||||
|
JobsAPI.readRelaunch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
can_start_without_user_input: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/jobs/9000'],
|
||||||
|
});
|
||||||
|
JobsAPI.relaunch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<LaunchButton
|
||||||
|
resource={{
|
||||||
|
id: 1,
|
||||||
|
type: 'job',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relaunchButton}
|
||||||
|
</LaunchButton>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: { history },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
button.prop('onClick')();
|
||||||
|
expect(JobsAPI.readRelaunch).toHaveBeenCalledWith(1);
|
||||||
|
await sleep(0);
|
||||||
|
expect(JobsAPI.relaunch).toHaveBeenCalledWith(1);
|
||||||
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should relaunch workflow job correctly', async () => {
|
||||||
|
WorkflowJobsAPI.readRelaunch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
can_start_without_user_input: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/jobs/9000'],
|
||||||
|
});
|
||||||
|
WorkflowJobsAPI.relaunch.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<LaunchButton
|
||||||
|
resource={{
|
||||||
|
id: 1,
|
||||||
|
type: 'workflow_job',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relaunchButton}
|
||||||
|
</LaunchButton>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: { history },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
button.prop('onClick')();
|
||||||
|
expect(WorkflowJobsAPI.readRelaunch).toHaveBeenCalledWith(1);
|
||||||
|
await sleep(0);
|
||||||
|
expect(WorkflowJobsAPI.relaunch).toHaveBeenCalledWith(1);
|
||||||
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should relaunch project sync correctly', async () => {
|
||||||
|
ProjectsAPI.readLaunchUpdate.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
can_start_without_user_input: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/jobs/9000'],
|
||||||
|
});
|
||||||
|
ProjectsAPI.launchUpdate.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<LaunchButton
|
||||||
|
resource={{
|
||||||
|
id: 1,
|
||||||
|
project: 5,
|
||||||
|
type: 'project_update',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relaunchButton}
|
||||||
|
</LaunchButton>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: { history },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
button.prop('onClick')();
|
||||||
|
expect(ProjectsAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
|
||||||
|
await sleep(0);
|
||||||
|
expect(ProjectsAPI.launchUpdate).toHaveBeenCalledWith(5);
|
||||||
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should relaunch project sync correctly', async () => {
|
||||||
|
InventorySourcesAPI.readLaunchUpdate.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
can_start_without_user_input: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/jobs/9000'],
|
||||||
|
});
|
||||||
|
InventorySourcesAPI.launchUpdate.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 9000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<LaunchButton
|
||||||
|
resource={{
|
||||||
|
id: 1,
|
||||||
|
inventory_source: 5,
|
||||||
|
type: 'inventory_update',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relaunchButton}
|
||||||
|
</LaunchButton>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: { history },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const button = wrapper.find('button');
|
||||||
|
button.prop('onClick')();
|
||||||
|
expect(InventorySourcesAPI.readLaunchUpdate).toHaveBeenCalledWith(5);
|
||||||
|
await sleep(0);
|
||||||
|
expect(InventorySourcesAPI.launchUpdate).toHaveBeenCalledWith(5);
|
||||||
|
expect(history.location.pathname).toEqual('/jobs/9000/output');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays error modal after unsuccessful launch', async () => {
|
test('displays error modal after unsuccessful launch', async () => {
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<LaunchButton resource={resource}>{children}</LaunchButton>
|
<LaunchButton resource={resource}>{launchButton}</LaunchButton>
|
||||||
);
|
);
|
||||||
JobTemplatesAPI.launch.mockRejectedValue(
|
JobTemplatesAPI.launch.mockRejectedValue(
|
||||||
new Error({
|
new Error({
|
||||||
|
|||||||
@@ -19,17 +19,18 @@ function PromptModalForm({
|
|||||||
resource,
|
resource,
|
||||||
surveyConfig,
|
surveyConfig,
|
||||||
}) {
|
}) {
|
||||||
const { values, setTouched, validateForm } = useFormikContext();
|
const { setFieldTouched, values } = useFormikContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
steps,
|
steps,
|
||||||
isReady,
|
isReady,
|
||||||
|
validateStep,
|
||||||
visitStep,
|
visitStep,
|
||||||
visitAllSteps,
|
visitAllSteps,
|
||||||
contentError,
|
contentError,
|
||||||
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
|
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSubmit = () => {
|
||||||
const postValues = {};
|
const postValues = {};
|
||||||
const setValue = (key, value) => {
|
const setValue = (key, value) => {
|
||||||
if (typeof value !== 'undefined' && value !== null) {
|
if (typeof value !== 'undefined' && value !== null) {
|
||||||
@@ -37,6 +38,7 @@ function PromptModalForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const surveyValues = getSurveyValues(values);
|
const surveyValues = getSurveyValues(values);
|
||||||
|
setValue('credential_passwords', values.credential_passwords);
|
||||||
setValue('inventory_id', values.inventory?.id);
|
setValue('inventory_id', values.inventory?.id);
|
||||||
setValue(
|
setValue(
|
||||||
'credentials',
|
'credentials',
|
||||||
@@ -75,22 +77,25 @@ function PromptModalForm({
|
|||||||
<Wizard
|
<Wizard
|
||||||
isOpen
|
isOpen
|
||||||
onClose={onCancel}
|
onClose={onCancel}
|
||||||
onSave={handleSave}
|
onSave={handleSubmit}
|
||||||
|
onBack={async nextStep => {
|
||||||
|
validateStep(nextStep.id);
|
||||||
|
}}
|
||||||
onNext={async (nextStep, prevStep) => {
|
onNext={async (nextStep, prevStep) => {
|
||||||
if (nextStep.id === 'preview') {
|
if (nextStep.id === 'preview') {
|
||||||
visitAllSteps(setTouched);
|
visitAllSteps(setFieldTouched);
|
||||||
} else {
|
} else {
|
||||||
visitStep(prevStep.prevId);
|
visitStep(prevStep.prevId, setFieldTouched);
|
||||||
|
validateStep(nextStep.id);
|
||||||
}
|
}
|
||||||
await validateForm();
|
|
||||||
}}
|
}}
|
||||||
onGoToStep={async (nextStep, prevStep) => {
|
onGoToStep={async (nextStep, prevStep) => {
|
||||||
if (nextStep.id === 'preview') {
|
if (nextStep.id === 'preview') {
|
||||||
visitAllSteps(setTouched);
|
visitAllSteps(setFieldTouched);
|
||||||
} else {
|
} else {
|
||||||
visitStep(prevStep.prevId);
|
visitStep(prevStep.prevId, setFieldTouched);
|
||||||
|
validateStep(nextStep.id);
|
||||||
}
|
}
|
||||||
await validateForm();
|
|
||||||
}}
|
}}
|
||||||
title={i18n._(t`Prompts`)}
|
title={i18n._(t`Prompts`)}
|
||||||
steps={
|
steps={
|
||||||
|
|||||||
@@ -82,8 +82,26 @@ describe('LaunchPrompt', () => {
|
|||||||
ask_credential_on_launch: true,
|
ask_credential_on_launch: true,
|
||||||
ask_scm_branch_on_launch: true,
|
ask_scm_branch_on_launch: true,
|
||||||
survey_enabled: true,
|
survey_enabled: true,
|
||||||
|
passwords_needed_to_start: ['ssh_password'],
|
||||||
|
defaults: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
passwords_needed: ['ssh_password'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
resource={{
|
||||||
|
...resource,
|
||||||
|
summary_fields: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
resource={resource}
|
|
||||||
onLaunch={noop}
|
onLaunch={noop}
|
||||||
onCancel={noop}
|
onCancel={noop}
|
||||||
surveyConfig={{
|
surveyConfig={{
|
||||||
@@ -110,12 +128,13 @@ describe('LaunchPrompt', () => {
|
|||||||
const wizard = await waitForElement(wrapper, 'Wizard');
|
const wizard = await waitForElement(wrapper, 'Wizard');
|
||||||
const steps = wizard.prop('steps');
|
const steps = wizard.prop('steps');
|
||||||
|
|
||||||
expect(steps).toHaveLength(5);
|
expect(steps).toHaveLength(6);
|
||||||
expect(steps[0].name.props.children).toEqual('Inventory');
|
expect(steps[0].name.props.children).toEqual('Inventory');
|
||||||
expect(steps[1].name.props.children).toEqual('Credentials');
|
expect(steps[1].name.props.children).toEqual('Credentials');
|
||||||
expect(steps[2].name.props.children).toEqual('Other prompts');
|
expect(steps[2].name.props.children).toEqual('Credential passwords');
|
||||||
expect(steps[3].name.props.children).toEqual('Survey');
|
expect(steps[3].name.props.children).toEqual('Other prompts');
|
||||||
expect(steps[4].name.props.children).toEqual('Preview');
|
expect(steps[4].name.props.children).toEqual('Survey');
|
||||||
|
expect(steps[5].name.props.children).toEqual('Preview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add inventory step', async () => {
|
test('should add inventory step', async () => {
|
||||||
|
|||||||
@@ -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 { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
|
import { Alert } from '@patternfly/react-core';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
@@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function InventoryStep({ i18n }) {
|
function InventoryStep({ i18n }) {
|
||||||
const [field, , helpers] = useField({
|
const [field, meta, helpers] = useField({
|
||||||
name: 'inventory',
|
name: 'inventory',
|
||||||
});
|
});
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -65,40 +67,45 @@ function InventoryStep({ i18n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionsList
|
<>
|
||||||
value={field.value ? [field.value] : []}
|
<OptionsList
|
||||||
options={inventories}
|
value={field.value ? [field.value] : []}
|
||||||
optionCount={count}
|
options={inventories}
|
||||||
searchColumns={[
|
optionCount={count}
|
||||||
{
|
searchColumns={[
|
||||||
name: i18n._(t`Name`),
|
{
|
||||||
key: 'name__icontains',
|
name: i18n._(t`Name`),
|
||||||
isDefault: true,
|
key: 'name__icontains',
|
||||||
},
|
isDefault: true,
|
||||||
{
|
},
|
||||||
name: i18n._(t`Created By (Username)`),
|
{
|
||||||
key: 'created_by__username__icontains',
|
name: i18n._(t`Created By (Username)`),
|
||||||
},
|
key: 'created_by__username__icontains',
|
||||||
{
|
},
|
||||||
name: i18n._(t`Modified By (Username)`),
|
{
|
||||||
key: 'modified_by__username__icontains',
|
name: i18n._(t`Modified By (Username)`),
|
||||||
},
|
key: 'modified_by__username__icontains',
|
||||||
]}
|
},
|
||||||
sortColumns={[
|
]}
|
||||||
{
|
sortColumns={[
|
||||||
name: i18n._(t`Name`),
|
{
|
||||||
key: 'name',
|
name: i18n._(t`Name`),
|
||||||
},
|
key: 'name',
|
||||||
]}
|
},
|
||||||
searchableKeys={searchableKeys}
|
]}
|
||||||
relatedSearchableKeys={relatedSearchableKeys}
|
searchableKeys={searchableKeys}
|
||||||
header={i18n._(t`Inventory`)}
|
relatedSearchableKeys={relatedSearchableKeys}
|
||||||
name="inventory"
|
header={i18n._(t`Inventory`)}
|
||||||
qsConfig={QS_CONFIG}
|
name="inventory"
|
||||||
readOnly
|
qsConfig={QS_CONFIG}
|
||||||
selectItem={helpers.setValue}
|
readOnly
|
||||||
deselectItem={() => field.onChange(null)}
|
selectItem={helpers.setValue}
|
||||||
/>
|
deselectItem={() => field.onChange(null)}
|
||||||
|
/>
|
||||||
|
{meta.touched && meta.error && (
|
||||||
|
<Alert variant="danger" isInline title={meta.error} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
step: getStep(launchConfig, i18n),
|
step: getStep(launchConfig, i18n),
|
||||||
initialValues: getInitialValues(launchConfig, resource),
|
initialValues: getInitialValues(launchConfig, resource),
|
||||||
validate: () => ({}),
|
|
||||||
isReady: true,
|
isReady: true,
|
||||||
contentError: null,
|
contentError: null,
|
||||||
formError: null,
|
hasError: false,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldTouched => {
|
||||||
setFieldsTouched({
|
setFieldTouched('credentials', true, false);
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
validate: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,27 @@ export default function useInventoryStep(
|
|||||||
i18n,
|
i18n,
|
||||||
visitedSteps
|
visitedSteps
|
||||||
) {
|
) {
|
||||||
const [, meta] = useField('inventory');
|
const [, meta, helpers] = useField('inventory');
|
||||||
const formError =
|
const formError =
|
||||||
Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error);
|
!resource || resource?.type === 'workflow_job_template'
|
||||||
|
? false
|
||||||
|
: Object.keys(visitedSteps).includes(STEP_ID) &&
|
||||||
|
meta.touched &&
|
||||||
|
!meta.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: getStep(launchConfig, i18n, formError),
|
step: getStep(launchConfig, i18n, formError),
|
||||||
initialValues: getInitialValues(launchConfig, resource),
|
initialValues: getInitialValues(launchConfig, resource),
|
||||||
isReady: true,
|
isReady: true,
|
||||||
contentError: null,
|
contentError: null,
|
||||||
formError: launchConfig.ask_inventory_on_launch && formError,
|
hasError: launchConfig.ask_inventory_on_launch && formError,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldTouched => {
|
||||||
setFieldsTouched({
|
setFieldTouched('inventory', true, false);
|
||||||
inventory: true,
|
},
|
||||||
});
|
validate: () => {
|
||||||
|
if (meta.touched && !meta.value && resource.type === 'job_template') {
|
||||||
|
helpers.setError(i18n._(t`An inventory must be selected`));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) {
|
|||||||
initialValues: getInitialValues(launchConfig, resource),
|
initialValues: getInitialValues(launchConfig, resource),
|
||||||
isReady: true,
|
isReady: true,
|
||||||
contentError: null,
|
contentError: null,
|
||||||
formError: null,
|
hasError: false,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldTouched => {
|
||||||
setFieldsTouched({
|
[
|
||||||
job_type: true,
|
'job_type',
|
||||||
limit: true,
|
'limit',
|
||||||
verbosity: true,
|
'verbosity',
|
||||||
diff_mode: true,
|
'diff_mode',
|
||||||
job_tags: true,
|
'job_tags',
|
||||||
skip_tags: true,
|
'skip_tags',
|
||||||
extra_vars: true,
|
'extra_vars',
|
||||||
});
|
].forEach(field => setFieldTouched(field, true, false));
|
||||||
},
|
},
|
||||||
|
validate: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ export default function usePreviewStep(
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
initialValues: {},
|
initialValues: {},
|
||||||
validate: () => ({}),
|
|
||||||
isReady: true,
|
isReady: true,
|
||||||
error: null,
|
error: null,
|
||||||
setTouched: () => {},
|
setTouched: () => {},
|
||||||
|
validate: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,89 +13,51 @@ export default function useSurveyStep(
|
|||||||
i18n,
|
i18n,
|
||||||
visitedSteps
|
visitedSteps
|
||||||
) {
|
) {
|
||||||
const { values } = useFormikContext();
|
const { setFieldError, values } = useFormikContext();
|
||||||
const errors = {};
|
const hasError =
|
||||||
const validate = () => {
|
Object.keys(visitedSteps).includes(STEP_ID) &&
|
||||||
if (!launchConfig.survey_enabled || !surveyConfig?.spec) {
|
checkForError(launchConfig, surveyConfig, values);
|
||||||
return {};
|
|
||||||
}
|
|
||||||
surveyConfig.spec.forEach(question => {
|
|
||||||
const errMessage = validateField(
|
|
||||||
question,
|
|
||||||
values[`survey_${question.variable}`],
|
|
||||||
i18n
|
|
||||||
);
|
|
||||||
if (errMessage) {
|
|
||||||
errors[`survey_${question.variable}`] = errMessage;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
const formError = Object.keys(validate()).length > 0;
|
|
||||||
return {
|
return {
|
||||||
step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps),
|
step: launchConfig.survey_enabled
|
||||||
|
? {
|
||||||
|
id: STEP_ID,
|
||||||
|
name: (
|
||||||
|
<StepName hasErrors={hasError} id="survey-step">
|
||||||
|
{i18n._(t`Survey`)}
|
||||||
|
</StepName>
|
||||||
|
),
|
||||||
|
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
|
||||||
|
enableNext: true,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
initialValues: getInitialValues(launchConfig, surveyConfig, resource),
|
initialValues: getInitialValues(launchConfig, surveyConfig, resource),
|
||||||
validate,
|
|
||||||
surveyConfig,
|
surveyConfig,
|
||||||
isReady: true,
|
isReady: true,
|
||||||
contentError: null,
|
contentError: null,
|
||||||
formError,
|
hasError,
|
||||||
setTouched: setFieldsTouched => {
|
setTouched: setFieldTouched => {
|
||||||
if (!surveyConfig?.spec) {
|
if (!surveyConfig?.spec) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fields = {};
|
|
||||||
surveyConfig.spec.forEach(question => {
|
surveyConfig.spec.forEach(question => {
|
||||||
fields[`survey_${question.variable}`] = true;
|
setFieldTouched(`survey_${question.variable}`, true, false);
|
||||||
});
|
});
|
||||||
setFieldsTouched(fields);
|
|
||||||
},
|
},
|
||||||
};
|
validate: () => {
|
||||||
}
|
if (launchConfig.survey_enabled && surveyConfig.spec) {
|
||||||
|
surveyConfig.spec.forEach(question => {
|
||||||
function validateField(question, value, i18n) {
|
const errMessage = validateSurveyField(
|
||||||
const isTextField = ['text', 'textarea'].includes(question.type);
|
question,
|
||||||
const isNumeric = ['integer', 'float'].includes(question.type);
|
values[`survey_${question.variable}`],
|
||||||
if (isTextField && (value || value === 0)) {
|
i18n
|
||||||
if (question.min && value.length < question.min) {
|
);
|
||||||
return i18n._(t`This field must be at least ${question.min} characters`);
|
if (errMessage) {
|
||||||
}
|
setFieldError(`survey_${question.variable}`, errMessage);
|
||||||
if (question.max && value.length > question.max) {
|
}
|
||||||
return i18n._(t`This field must not exceed ${question.max} characters`);
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
if (isNumeric && (value || value === 0)) {
|
|
||||||
if (value < question.min || value > question.max) {
|
|
||||||
return i18n._(
|
|
||||||
t`This field must be a number and have a value between ${question.min} and ${question.max}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (question.required && !value && value !== 0) {
|
|
||||||
return i18n._(t`This field must not be blank`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) {
|
|
||||||
if (!launchConfig.survey_enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: STEP_ID,
|
|
||||||
name: (
|
|
||||||
<StepName
|
|
||||||
hasErrors={
|
|
||||||
Object.keys(visitedSteps).includes(STEP_ID) &&
|
|
||||||
Object.keys(validate()).length
|
|
||||||
}
|
|
||||||
id="survey-step"
|
|
||||||
>
|
|
||||||
{i18n._(t`Survey`)}
|
|
||||||
</StepName>
|
|
||||||
),
|
|
||||||
component: <SurveyStep surveyConfig={surveyConfig} i18n={i18n} />,
|
|
||||||
enableNext: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) {
|
|||||||
|
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateSurveyField(question, value, i18n) {
|
||||||
|
const isTextField = ['text', 'textarea'].includes(question.type);
|
||||||
|
const isNumeric = ['integer', 'float'].includes(question.type);
|
||||||
|
if (isTextField && (value || value === 0)) {
|
||||||
|
if (question.min && value.length < question.min) {
|
||||||
|
return i18n._(t`This field must be at least ${question.min} characters`);
|
||||||
|
}
|
||||||
|
if (question.max && value.length > question.max) {
|
||||||
|
return i18n._(t`This field must not exceed ${question.max} characters`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isNumeric && (value || value === 0)) {
|
||||||
|
if (value < question.min || value > question.max) {
|
||||||
|
return i18n._(
|
||||||
|
t`This field must be a number and have a value between ${question.min} and ${question.max}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (question.required && !value && value !== 0) {
|
||||||
|
return i18n._(t`This field must not be blank`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkForError(launchConfig, surveyConfig, values) {
|
||||||
|
let hasError = false;
|
||||||
|
if (launchConfig.survey_enabled && surveyConfig.spec) {
|
||||||
|
surveyConfig.spec.forEach(question => {
|
||||||
|
const value = values[`survey_${question.variable}`];
|
||||||
|
const isTextField = ['text', 'textarea'].includes(question.type);
|
||||||
|
const isNumeric = ['integer', 'float'].includes(question.type);
|
||||||
|
if (isTextField && (value || value === 0)) {
|
||||||
|
if (
|
||||||
|
(question.min && value.length < question.min) ||
|
||||||
|
(question.max && value.length > question.max)
|
||||||
|
) {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isNumeric && (value || value === 0)) {
|
||||||
|
if (value < question.min || value > question.max) {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (question.required && !value && value !== 0) {
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasError;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,43 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useFormikContext } from 'formik';
|
import { useFormikContext } from 'formik';
|
||||||
import useInventoryStep from './steps/useInventoryStep';
|
import useInventoryStep from './steps/useInventoryStep';
|
||||||
import useCredentialsStep from './steps/useCredentialsStep';
|
import useCredentialsStep from './steps/useCredentialsStep';
|
||||||
|
import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep';
|
||||||
import useOtherPromptsStep from './steps/useOtherPromptsStep';
|
import useOtherPromptsStep from './steps/useOtherPromptsStep';
|
||||||
import useSurveyStep from './steps/useSurveyStep';
|
import useSurveyStep from './steps/useSurveyStep';
|
||||||
import usePreviewStep from './steps/usePreviewStep';
|
import usePreviewStep from './steps/usePreviewStep';
|
||||||
|
|
||||||
|
function showCredentialPasswordsStep(credentials = [], launchConfig) {
|
||||||
|
if (
|
||||||
|
!launchConfig?.ask_credential_on_launch &&
|
||||||
|
launchConfig?.passwords_needed_to_start
|
||||||
|
) {
|
||||||
|
return launchConfig.passwords_needed_to_start.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentialPasswordStepRequired = false;
|
||||||
|
|
||||||
|
credentials.forEach(credential => {
|
||||||
|
if (!credential.inputs) {
|
||||||
|
const launchConfigCredential = launchConfig.defaults.credentials.find(
|
||||||
|
defaultCred => defaultCred.id === credential.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (launchConfigCredential?.passwords_needed.length > 0) {
|
||||||
|
credentialPasswordStepRequired = true;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
credential?.inputs?.password === 'ASK' ||
|
||||||
|
credential?.inputs?.become_password === 'ASK' ||
|
||||||
|
credential?.inputs?.ssh_key_unlock === 'ASK' ||
|
||||||
|
credential?.inputs?.vault_password === 'ASK'
|
||||||
|
) {
|
||||||
|
credentialPasswordStepRequired = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return credentialPasswordStepRequired;
|
||||||
|
}
|
||||||
|
|
||||||
export default function useLaunchSteps(
|
export default function useLaunchSteps(
|
||||||
launchConfig,
|
launchConfig,
|
||||||
surveyConfig,
|
surveyConfig,
|
||||||
@@ -14,14 +47,21 @@ export default function useLaunchSteps(
|
|||||||
) {
|
) {
|
||||||
const [visited, setVisited] = useState({});
|
const [visited, setVisited] = useState({});
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const { touched, values: formikValues } = useFormikContext();
|
||||||
const steps = [
|
const steps = [
|
||||||
useInventoryStep(launchConfig, resource, i18n, visited),
|
useInventoryStep(launchConfig, resource, i18n, visited),
|
||||||
useCredentialsStep(launchConfig, resource, i18n),
|
useCredentialsStep(launchConfig, resource, i18n),
|
||||||
|
useCredentialPasswordsStep(
|
||||||
|
launchConfig,
|
||||||
|
i18n,
|
||||||
|
showCredentialPasswordsStep(formikValues.credentials, launchConfig),
|
||||||
|
visited
|
||||||
|
),
|
||||||
useOtherPromptsStep(launchConfig, resource, i18n),
|
useOtherPromptsStep(launchConfig, resource, i18n),
|
||||||
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
|
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
|
||||||
];
|
];
|
||||||
const { resetForm } = useFormikContext();
|
const { resetForm } = useFormikContext();
|
||||||
const hasErrors = steps.some(step => step.formError);
|
const hasErrors = steps.some(step => step.hasError);
|
||||||
|
|
||||||
steps.push(
|
steps.push(
|
||||||
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
|
usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true)
|
||||||
@@ -38,16 +78,47 @@ export default function useLaunchSteps(
|
|||||||
...cur.initialValues,
|
...cur.initialValues,
|
||||||
};
|
};
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
const newFormValues = { ...initialValues };
|
||||||
|
|
||||||
|
Object.keys(formikValues).forEach(formikValueKey => {
|
||||||
|
if (
|
||||||
|
formikValueKey === 'credential_passwords' &&
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
newFormValues,
|
||||||
|
'credential_passwords'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const formikCredentialPasswords = formikValues.credential_passwords;
|
||||||
|
Object.keys(formikCredentialPasswords).forEach(
|
||||||
|
credentialPasswordValueKey => {
|
||||||
|
if (
|
||||||
|
Object.prototype.hasOwnProperty.call(
|
||||||
|
newFormValues.credential_passwords,
|
||||||
|
credentialPasswordValueKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
newFormValues.credential_passwords[credentialPasswordValueKey] =
|
||||||
|
formikCredentialPasswords[credentialPasswordValueKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey)
|
||||||
|
) {
|
||||||
|
newFormValues[formikValueKey] = formikValues[formikValueKey];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
resetForm({
|
resetForm({
|
||||||
values: {
|
values: newFormValues,
|
||||||
...initialValues,
|
touched,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [stepsAreReady]);
|
}, [formikValues.credentials, stepsAreReady]);
|
||||||
|
|
||||||
const stepWithError = steps.find(s => s.contentError);
|
const stepWithError = steps.find(s => s.contentError);
|
||||||
const contentError = stepWithError ? stepWithError.contentError : null;
|
const contentError = stepWithError ? stepWithError.contentError : null;
|
||||||
@@ -55,20 +126,26 @@ export default function useLaunchSteps(
|
|||||||
return {
|
return {
|
||||||
steps: pfSteps,
|
steps: pfSteps,
|
||||||
isReady,
|
isReady,
|
||||||
visitStep: stepId =>
|
validateStep: stepId => {
|
||||||
|
steps.find(s => s?.step?.id === stepId).validate();
|
||||||
|
},
|
||||||
|
visitStep: (prevStepId, setFieldTouched) => {
|
||||||
setVisited({
|
setVisited({
|
||||||
...visited,
|
...visited,
|
||||||
[stepId]: true,
|
[prevStepId]: true,
|
||||||
}),
|
});
|
||||||
visitAllSteps: setFieldsTouched => {
|
steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched);
|
||||||
|
},
|
||||||
|
visitAllSteps: setFieldTouched => {
|
||||||
setVisited({
|
setVisited({
|
||||||
inventory: true,
|
inventory: true,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
credentialPasswords: true,
|
||||||
other: true,
|
other: true,
|
||||||
survey: true,
|
survey: true,
|
||||||
preview: true,
|
preview: true,
|
||||||
});
|
});
|
||||||
steps.forEach(s => s.setTouched(setFieldsTouched));
|
steps.forEach(s => s.setTouched(setFieldTouched));
|
||||||
},
|
},
|
||||||
contentError,
|
contentError,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,12 @@ class ListHeader extends React.Component {
|
|||||||
pushHistoryState(params) {
|
pushHistoryState(params) {
|
||||||
const { history, qsConfig } = this.props;
|
const { history, qsConfig } = this.props;
|
||||||
const { pathname } = history.location;
|
const { pathname } = history.location;
|
||||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
const nonNamespacedParams = parseQueryString({}, history.location.search);
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(
|
||||||
|
qsConfig,
|
||||||
|
params,
|
||||||
|
nonNamespacedParams
|
||||||
|
);
|
||||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FormGroup,
|
FormGroup,
|
||||||
InputGroup,
|
InputGroup,
|
||||||
Modal,
|
Modal,
|
||||||
|
Tooltip,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import ChipGroup from '../ChipGroup';
|
import ChipGroup from '../ChipGroup';
|
||||||
import Popover from '../Popover';
|
import Popover from '../Popover';
|
||||||
@@ -243,6 +244,36 @@ function HostFilterLookup({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderLookup = () => (
|
||||||
|
<InputGroup onBlur={onBlur}>
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Search`)}
|
||||||
|
id="host-filter"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant={ButtonVariant.control}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</Button>
|
||||||
|
<ChipHolder className="pf-c-form-control">
|
||||||
|
{searchColumns.map(({ name, key }) => (
|
||||||
|
<ChipGroup
|
||||||
|
categoryName={name}
|
||||||
|
key={name}
|
||||||
|
numChips={5}
|
||||||
|
totalChips={chips[key]?.chips?.length || 0}
|
||||||
|
>
|
||||||
|
{chips[key]?.chips?.map(chip => (
|
||||||
|
<Chip key={chip.key} isReadOnly>
|
||||||
|
{chip.node}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
))}
|
||||||
|
</ChipHolder>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="host-filter"
|
fieldId="host-filter"
|
||||||
@@ -261,33 +292,17 @@ function HostFilterLookup({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InputGroup onBlur={onBlur}>
|
{isDisabled ? (
|
||||||
<Button
|
<Tooltip
|
||||||
aria-label={i18n._(t`Search`)}
|
content={i18n._(
|
||||||
id="host-filter"
|
t`Please select an organization before editing the host filter`
|
||||||
isDisabled={isDisabled}
|
)}
|
||||||
onClick={handleOpenModal}
|
|
||||||
variant={ButtonVariant.control}
|
|
||||||
>
|
>
|
||||||
<SearchIcon />
|
{renderLookup()}
|
||||||
</Button>
|
</Tooltip>
|
||||||
<ChipHolder className="pf-c-form-control">
|
) : (
|
||||||
{searchColumns.map(({ name, key }) => (
|
renderLookup()
|
||||||
<ChipGroup
|
)}
|
||||||
categoryName={name}
|
|
||||||
key={name}
|
|
||||||
numChips={5}
|
|
||||||
totalChips={chips[key]?.chips?.length || 0}
|
|
||||||
>
|
|
||||||
{chips[key]?.chips?.map(chip => (
|
|
||||||
<Chip key={chip.key} isReadOnly>
|
|
||||||
{chip.node}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</ChipGroup>
|
|
||||||
))}
|
|
||||||
</ChipHolder>
|
|
||||||
</InputGroup>
|
|
||||||
<Modal
|
<Modal
|
||||||
aria-label={i18n._(t`Lookup modal`)}
|
aria-label={i18n._(t`Lookup modal`)}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -61,7 +61,12 @@ function PaginatedDataList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pushHistoryState = params => {
|
const pushHistoryState = params => {
|
||||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
const nonNamespacedParams = parseQueryString({}, history.location.search);
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(
|
||||||
|
qsConfig,
|
||||||
|
params,
|
||||||
|
nonNamespacedParams
|
||||||
|
);
|
||||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export default function HeaderRow({ qsConfig, children }) {
|
|||||||
order_by: order === 'asc' ? key : `-${key}`,
|
order_by: order === 'asc' ? key : `-${key}`,
|
||||||
page: null,
|
page: null,
|
||||||
});
|
});
|
||||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, newParams);
|
const nonNamespacedParams = parseQueryString({}, history.location.search);
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(
|
||||||
|
qsConfig,
|
||||||
|
newParams,
|
||||||
|
nonNamespacedParams
|
||||||
|
);
|
||||||
history.push(
|
history.push(
|
||||||
encodedParams
|
encodedParams
|
||||||
? `${location.pathname}?${encodedParams}`
|
? `${location.pathname}?${encodedParams}`
|
||||||
|
|||||||
@@ -40,8 +40,13 @@ function PaginatedTable({
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const pushHistoryState = params => {
|
const pushHistoryState = params => {
|
||||||
const { pathname } = history.location;
|
const { pathname, search } = history.location;
|
||||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
const nonNamespacedParams = parseQueryString({}, search);
|
||||||
|
const encodedParams = encodeNonDefaultQueryString(
|
||||||
|
qsConfig,
|
||||||
|
params,
|
||||||
|
nonNamespacedParams
|
||||||
|
);
|
||||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,69 +7,71 @@ import { t } from '@lingui/macro';
|
|||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import { Role } from '../../types';
|
import { Role } from '../../types';
|
||||||
|
|
||||||
class DeleteRoleConfirmationModal extends React.Component {
|
function DeleteRoleConfirmationModal({
|
||||||
static propTypes = {
|
role,
|
||||||
role: Role.isRequired,
|
username,
|
||||||
username: string,
|
onCancel,
|
||||||
onCancel: func.isRequired,
|
onConfirm,
|
||||||
onConfirm: func.isRequired,
|
i18n,
|
||||||
};
|
}) {
|
||||||
|
const isTeamRole = () => {
|
||||||
static defaultProps = {
|
|
||||||
username: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
isTeamRole() {
|
|
||||||
const { role } = this.props;
|
|
||||||
return typeof role.team_id !== 'undefined';
|
return typeof role.team_id !== 'undefined';
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const title = i18n._(
|
||||||
const { role, username, onCancel, onConfirm, i18n } = this.props;
|
t`Remove ${isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
|
||||||
const title = i18n._(
|
);
|
||||||
t`Remove ${this.isTeamRole() ? i18n._(t`Team`) : i18n._(t`User`)} Access`
|
return (
|
||||||
);
|
<AlertModal
|
||||||
return (
|
variant="danger"
|
||||||
<AlertModal
|
title={title}
|
||||||
variant="danger"
|
isOpen
|
||||||
title={title}
|
onClose={onCancel}
|
||||||
isOpen
|
actions={[
|
||||||
onClose={onCancel}
|
<Button
|
||||||
actions={[
|
key="delete"
|
||||||
<Button
|
variant="danger"
|
||||||
key="delete"
|
aria-label={i18n._(t`Confirm delete`)}
|
||||||
variant="danger"
|
onClick={onConfirm}
|
||||||
aria-label={i18n._(t`Confirm delete`)}
|
>
|
||||||
onClick={onConfirm}
|
{i18n._(t`Delete`)}
|
||||||
>
|
</Button>,
|
||||||
{i18n._(t`Delete`)}
|
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
||||||
</Button>,
|
{i18n._(t`Cancel`)}
|
||||||
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
</Button>,
|
||||||
{i18n._(t`Cancel`)}
|
]}
|
||||||
</Button>,
|
>
|
||||||
]}
|
{isTeamRole() ? (
|
||||||
>
|
<Fragment>
|
||||||
{this.isTeamRole() ? (
|
{i18n._(
|
||||||
<Fragment>
|
t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.`
|
||||||
{i18n._(
|
)}
|
||||||
t`Are you sure you want to remove ${role.name} access from ${role.team_name}? Doing so affects all members of the team.`
|
<br />
|
||||||
)}
|
<br />
|
||||||
<br />
|
{i18n._(
|
||||||
<br />
|
t`If you only want to remove access for this particular user, please remove them from the team.`
|
||||||
{i18n._(
|
)}
|
||||||
t`If you only want to remove access for this particular user, please remove them from the team.`
|
</Fragment>
|
||||||
)}
|
) : (
|
||||||
</Fragment>
|
<Fragment>
|
||||||
) : (
|
{i18n._(
|
||||||
<Fragment>
|
t`Are you sure you want to remove ${role.name} access from ${username}?`
|
||||||
{i18n._(
|
)}
|
||||||
t`Are you sure you want to remove ${role.name} access from ${username}?`
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</AlertModal>
|
||||||
)}
|
);
|
||||||
</AlertModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DeleteRoleConfirmationModal.propTypes = {
|
||||||
|
role: Role.isRequired,
|
||||||
|
username: string,
|
||||||
|
onCancel: func.isRequired,
|
||||||
|
onConfirm: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
DeleteRoleConfirmationModal.defaultProps = {
|
||||||
|
username: '',
|
||||||
|
};
|
||||||
|
|
||||||
export default withI18n()(DeleteRoleConfirmationModal);
|
export default withI18n()(DeleteRoleConfirmationModal);
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
|||||||
setDeletionRole(role);
|
setDeletionRole(role);
|
||||||
setShowDeleteModal(true);
|
setShowDeleteModal(true);
|
||||||
}}
|
}}
|
||||||
|
i18n={i18n}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,19 +24,13 @@ const DataListItemCells = styled(PFDataListItemCells)`
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class ResourceAccessListItem extends React.Component {
|
function ResourceAccessListItem({ accessRecord, onRoleDelete, i18n }) {
|
||||||
static propTypes = {
|
ResourceAccessListItem.propTypes = {
|
||||||
accessRecord: AccessRecord.isRequired,
|
accessRecord: AccessRecord.isRequired,
|
||||||
onRoleDelete: func.isRequired,
|
onRoleDelete: func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(props) {
|
const getRoleLists = () => {
|
||||||
super(props);
|
|
||||||
this.renderChip = this.renderChip.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRoleLists() {
|
|
||||||
const { accessRecord } = this.props;
|
|
||||||
const teamRoles = [];
|
const teamRoles = [];
|
||||||
const userRoles = [];
|
const userRoles = [];
|
||||||
|
|
||||||
@@ -52,10 +46,9 @@ class ResourceAccessListItem extends React.Component {
|
|||||||
accessRecord.summary_fields.direct_access.map(sort);
|
accessRecord.summary_fields.direct_access.map(sort);
|
||||||
accessRecord.summary_fields.indirect_access.map(sort);
|
accessRecord.summary_fields.indirect_access.map(sort);
|
||||||
return [teamRoles, userRoles];
|
return [teamRoles, userRoles];
|
||||||
}
|
};
|
||||||
|
|
||||||
renderChip(role) {
|
const renderChip = role => {
|
||||||
const { accessRecord, onRoleDelete } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
key={role.id}
|
key={role.id}
|
||||||
@@ -67,79 +60,76 @@ class ResourceAccessListItem extends React.Component {
|
|||||||
{role.name}
|
{role.name}
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const [teamRoles, userRoles] = getRoleLists();
|
||||||
const { accessRecord, i18n } = this.props;
|
|
||||||
const [teamRoles, userRoles] = this.getRoleLists();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<DataListItem
|
||||||
aria-labelledby="access-list-item"
|
aria-labelledby="access-list-item"
|
||||||
key={accessRecord.id}
|
key={accessRecord.id}
|
||||||
id={`${accessRecord.id}`}
|
id={`${accessRecord.id}`}
|
||||||
>
|
>
|
||||||
<DataListItemRow>
|
<DataListItemRow>
|
||||||
<DataListItemCells
|
<DataListItemCells
|
||||||
dataListCells={[
|
dataListCells={[
|
||||||
<DataListCell key="name">
|
<DataListCell key="name">
|
||||||
{accessRecord.username && (
|
{accessRecord.username && (
|
||||||
<TextContent>
|
<TextContent>
|
||||||
{accessRecord.id ? (
|
{accessRecord.id ? (
|
||||||
<Text component={TextVariants.h6}>
|
<Text component={TextVariants.h6}>
|
||||||
<Link
|
<Link
|
||||||
to={{ pathname: `/users/${accessRecord.id}/details` }}
|
to={{ pathname: `/users/${accessRecord.id}/details` }}
|
||||||
css="font-weight: bold"
|
css="font-weight: bold"
|
||||||
>
|
>
|
||||||
{accessRecord.username}
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text component={TextVariants.h6} css="font-weight: bold">
|
|
||||||
{accessRecord.username}
|
{accessRecord.username}
|
||||||
</Text>
|
</Link>
|
||||||
)}
|
</Text>
|
||||||
</TextContent>
|
) : (
|
||||||
)}
|
<Text component={TextVariants.h6} css="font-weight: bold">
|
||||||
{accessRecord.first_name || accessRecord.last_name ? (
|
{accessRecord.username}
|
||||||
<DetailList stacked>
|
</Text>
|
||||||
<Detail
|
)}
|
||||||
label={i18n._(t`Name`)}
|
</TextContent>
|
||||||
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
|
)}
|
||||||
/>
|
{accessRecord.first_name || accessRecord.last_name ? (
|
||||||
</DetailList>
|
|
||||||
) : null}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="roles">
|
|
||||||
<DetailList stacked>
|
<DetailList stacked>
|
||||||
{userRoles.length > 0 && (
|
<Detail
|
||||||
<Detail
|
label={i18n._(t`Name`)}
|
||||||
label={i18n._(t`User Roles`)}
|
value={`${accessRecord.first_name} ${accessRecord.last_name}`}
|
||||||
value={
|
/>
|
||||||
<ChipGroup numChips={5} totalChips={userRoles.length}>
|
|
||||||
{userRoles.map(this.renderChip)}
|
|
||||||
</ChipGroup>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{teamRoles.length > 0 && (
|
|
||||||
<Detail
|
|
||||||
label={i18n._(t`Team Roles`)}
|
|
||||||
value={
|
|
||||||
<ChipGroup numChips={5} totalChips={teamRoles.length}>
|
|
||||||
{teamRoles.map(this.renderChip)}
|
|
||||||
</ChipGroup>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DetailList>
|
</DetailList>
|
||||||
</DataListCell>,
|
) : null}
|
||||||
]}
|
</DataListCell>,
|
||||||
/>
|
<DataListCell key="roles">
|
||||||
</DataListItemRow>
|
<DetailList stacked>
|
||||||
</DataListItem>
|
{userRoles.length > 0 && (
|
||||||
);
|
<Detail
|
||||||
}
|
label={i18n._(t`User Roles`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5} totalChips={userRoles.length}>
|
||||||
|
{userRoles.map(renderChip)}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{teamRoles.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Team Roles`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5} totalChips={teamRoles.length}>
|
||||||
|
{teamRoles.map(renderChip)}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DetailList>
|
||||||
|
</DataListCell>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DataListItemRow>
|
||||||
|
</DataListItem>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(ResourceAccessListItem);
|
export default withI18n()(ResourceAccessListItem);
|
||||||
|
|||||||
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 React from 'react';
|
||||||
import { mount } from 'enzyme';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import Breadcrumbs from './Breadcrumbs';
|
|
||||||
|
|
||||||
describe('<Breadcrumb />', () => {
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
|
import ScreenHeader from './ScreenHeader';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('<ScreenHeader />', () => {
|
||||||
let breadcrumbWrapper;
|
let breadcrumbWrapper;
|
||||||
let breadcrumb;
|
let breadcrumb;
|
||||||
let breadcrumbItem;
|
let breadcrumbItem;
|
||||||
@@ -17,15 +23,15 @@ describe('<Breadcrumb />', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const findChildren = () => {
|
const findChildren = () => {
|
||||||
breadcrumb = breadcrumbWrapper.find('Breadcrumb');
|
breadcrumb = breadcrumbWrapper.find('ScreenHeader');
|
||||||
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
|
breadcrumbItem = breadcrumbWrapper.find('BreadcrumbItem');
|
||||||
breadcrumbHeading = breadcrumbWrapper.find('BreadcrumbHeading');
|
breadcrumbHeading = breadcrumbWrapper.find('Title');
|
||||||
};
|
};
|
||||||
|
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
breadcrumbWrapper = mount(
|
breadcrumbWrapper = mountWithContexts(
|
||||||
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
|
<MemoryRouter initialEntries={['/foo/1/bar']} initialIndex={0}>
|
||||||
<Breadcrumbs breadcrumbConfig={config} />
|
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,9 +57,9 @@ describe('<Breadcrumb />', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
routes.forEach(([location, crumbLength]) => {
|
routes.forEach(([location, crumbLength]) => {
|
||||||
breadcrumbWrapper = mount(
|
breadcrumbWrapper = mountWithContexts(
|
||||||
<MemoryRouter initialEntries={[location]}>
|
<MemoryRouter initialEntries={[location]}>
|
||||||
<Breadcrumbs breadcrumbConfig={config} />
|
<ScreenHeader streamType="all_activity" breadcrumbConfig={config} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
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 PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { useLocation, withRouter } from 'react-router-dom';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -31,140 +31,110 @@ const NoOptionDropdown = styled.div`
|
|||||||
border-bottom-color: var(--pf-global--BorderColor--200);
|
border-bottom-color: var(--pf-global--BorderColor--200);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class Sort extends React.Component {
|
function Sort({ columns, qsConfig, onSort, i18n }) {
|
||||||
constructor(props) {
|
const location = useLocation();
|
||||||
super(props);
|
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||||
|
|
||||||
let sortKey;
|
let sortKey;
|
||||||
let sortOrder;
|
let sortOrder;
|
||||||
let isNumeric;
|
let isNumeric;
|
||||||
|
|
||||||
const { qsConfig, location } = this.props;
|
const queryParams = parseQueryString(qsConfig, location.search);
|
||||||
const queryParams = parseQueryString(qsConfig, location.search);
|
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
|
||||||
if (queryParams.order_by && queryParams.order_by.startsWith('-')) {
|
sortKey = queryParams.order_by.substr(1);
|
||||||
sortKey = queryParams.order_by.substr(1);
|
sortOrder = 'descending';
|
||||||
sortOrder = 'descending';
|
} else if (queryParams.order_by) {
|
||||||
} else if (queryParams.order_by) {
|
sortKey = queryParams.order_by;
|
||||||
sortKey = queryParams.order_by;
|
sortOrder = 'ascending';
|
||||||
sortOrder = 'ascending';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
|
||||||
isNumeric = true;
|
|
||||||
} else {
|
|
||||||
isNumeric = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isSortDropdownOpen: false,
|
|
||||||
sortKey,
|
|
||||||
sortOrder,
|
|
||||||
isNumeric,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
|
|
||||||
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
|
|
||||||
this.handleSort = this.handleSort.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownToggle(isSortDropdownOpen) {
|
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
||||||
this.setState({ isSortDropdownOpen });
|
isNumeric = true;
|
||||||
|
} else {
|
||||||
|
isNumeric = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDropdownSelect({ target }) {
|
const handleDropdownToggle = isOpen => {
|
||||||
const { columns, onSort, qsConfig } = this.props;
|
setIsSortDropdownOpen(isOpen);
|
||||||
const { sortOrder } = this.state;
|
};
|
||||||
|
|
||||||
|
const handleDropdownSelect = ({ target }) => {
|
||||||
const { innerText } = target;
|
const { innerText } = target;
|
||||||
|
|
||||||
const [{ key: sortKey }] = columns.filter(({ name }) => name === innerText);
|
const [{ key }] = columns.filter(({ name }) => name === innerText);
|
||||||
|
sortKey = key;
|
||||||
let isNumeric;
|
if (qsConfig.integerFields.find(field => field === key)) {
|
||||||
|
|
||||||
if (qsConfig.integerFields.find(field => field === sortKey)) {
|
|
||||||
isNumeric = true;
|
isNumeric = true;
|
||||||
} else {
|
} else {
|
||||||
isNumeric = false;
|
isNumeric = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ isSortDropdownOpen: false, sortKey, isNumeric });
|
setIsSortDropdownOpen(false);
|
||||||
onSort(sortKey, sortOrder);
|
onSort(sortKey, sortOrder);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleSort() {
|
const handleSort = () => {
|
||||||
const { onSort } = this.props;
|
onSort(sortKey, sortOrder === 'ascending' ? 'descending' : 'ascending');
|
||||||
const { sortKey, sortOrder } = this.state;
|
};
|
||||||
const newSortOrder = sortOrder === 'ascending' ? 'descending' : 'ascending';
|
|
||||||
this.setState({ sortOrder: newSortOrder });
|
|
||||||
onSort(sortKey, newSortOrder);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
const { up } = DropdownPosition;
|
||||||
const { up } = DropdownPosition;
|
|
||||||
const { columns, i18n } = this.props;
|
|
||||||
const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state;
|
|
||||||
|
|
||||||
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
|
const defaultSortedColumn = columns.find(({ key }) => key === sortKey);
|
||||||
|
|
||||||
if (!defaultSortedColumn) {
|
if (!defaultSortedColumn) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />'
|
'sortKey must match one of the column keys, check the sortColumns prop passed to <Sort />'
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedColumnName = defaultSortedColumn?.name;
|
|
||||||
|
|
||||||
const sortDropdownItems = columns
|
|
||||||
.filter(({ key }) => key !== sortKey)
|
|
||||||
.map(({ key, name }) => (
|
|
||||||
<DropdownItem key={key} component="button">
|
|
||||||
{name}
|
|
||||||
</DropdownItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
let SortIcon;
|
|
||||||
if (isNumeric) {
|
|
||||||
SortIcon =
|
|
||||||
sortOrder === 'ascending'
|
|
||||||
? SortNumericDownIcon
|
|
||||||
: SortNumericDownAltIcon;
|
|
||||||
} else {
|
|
||||||
SortIcon =
|
|
||||||
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{sortedColumnName && (
|
|
||||||
<InputGroup>
|
|
||||||
{(sortDropdownItems.length > 0 && (
|
|
||||||
<Dropdown
|
|
||||||
onToggle={this.handleDropdownToggle}
|
|
||||||
onSelect={this.handleDropdownSelect}
|
|
||||||
direction={up}
|
|
||||||
isOpen={isSortDropdownOpen}
|
|
||||||
toggle={
|
|
||||||
<DropdownToggle
|
|
||||||
id="awx-sort"
|
|
||||||
onToggle={this.handleDropdownToggle}
|
|
||||||
>
|
|
||||||
{sortedColumnName}
|
|
||||||
</DropdownToggle>
|
|
||||||
}
|
|
||||||
dropdownItems={sortDropdownItems}
|
|
||||||
/>
|
|
||||||
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
|
|
||||||
<Button
|
|
||||||
variant={ButtonVariant.control}
|
|
||||||
aria-label={i18n._(t`Sort`)}
|
|
||||||
onClick={this.handleSort}
|
|
||||||
>
|
|
||||||
<SortIcon />
|
|
||||||
</Button>
|
|
||||||
</InputGroup>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedColumnName = defaultSortedColumn?.name;
|
||||||
|
|
||||||
|
const sortDropdownItems = columns
|
||||||
|
.filter(({ key }) => key !== sortKey)
|
||||||
|
.map(({ key, name }) => (
|
||||||
|
<DropdownItem key={key} component="button">
|
||||||
|
{name}
|
||||||
|
</DropdownItem>
|
||||||
|
));
|
||||||
|
|
||||||
|
let SortIcon;
|
||||||
|
if (isNumeric) {
|
||||||
|
SortIcon =
|
||||||
|
sortOrder === 'ascending' ? SortNumericDownIcon : SortNumericDownAltIcon;
|
||||||
|
} else {
|
||||||
|
SortIcon =
|
||||||
|
sortOrder === 'ascending' ? SortAlphaDownIcon : SortAlphaDownAltIcon;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{sortedColumnName && (
|
||||||
|
<InputGroup>
|
||||||
|
{(sortDropdownItems.length > 0 && (
|
||||||
|
<Dropdown
|
||||||
|
onToggle={handleDropdownToggle}
|
||||||
|
onSelect={handleDropdownSelect}
|
||||||
|
direction={up}
|
||||||
|
isOpen={isSortDropdownOpen}
|
||||||
|
toggle={
|
||||||
|
<DropdownToggle id="awx-sort" onToggle={handleDropdownToggle}>
|
||||||
|
{sortedColumnName}
|
||||||
|
</DropdownToggle>
|
||||||
|
}
|
||||||
|
dropdownItems={sortDropdownItems}
|
||||||
|
/>
|
||||||
|
)) || <NoOptionDropdown>{sortedColumnName}</NoOptionDropdown>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={ButtonVariant.control}
|
||||||
|
aria-label={i18n._(t`Sort`)}
|
||||||
|
onClick={handleSort}
|
||||||
|
>
|
||||||
|
<SortIcon />
|
||||||
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Sort.propTypes = {
|
Sort.propTypes = {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import Sort from './Sort';
|
import Sort from './Sort';
|
||||||
|
|
||||||
describe('<Sort />', () => {
|
describe('<Sort />', () => {
|
||||||
@@ -105,7 +110,7 @@ describe('<Sort />', () => {
|
|||||||
expect(onSort).toHaveBeenCalledWith('foo', 'ascending');
|
expect(onSort).toHaveBeenCalledWith('foo', 'ascending');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Changing dropdown correctly passes back new sort key', () => {
|
test('Changing dropdown correctly passes back new sort key', async () => {
|
||||||
const qsConfig = {
|
const qsConfig = {
|
||||||
namespace: 'item',
|
namespace: 'item',
|
||||||
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
|
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
|
||||||
@@ -131,44 +136,18 @@ describe('<Sort />', () => {
|
|||||||
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
|
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
|
||||||
).find('Sort');
|
);
|
||||||
|
act(() => wrapper.find('Dropdown').invoke('onToggle')(true));
|
||||||
wrapper.instance().handleDropdownSelect({ target: { innerText: 'Bar' } });
|
wrapper.update();
|
||||||
|
await waitForElement(wrapper, 'Dropdown', el => el.prop('isOpen') === true);
|
||||||
|
wrapper
|
||||||
|
.find('li')
|
||||||
|
.at(0)
|
||||||
|
.prop('onClick')({ target: { innerText: 'Bar' } });
|
||||||
|
wrapper.update();
|
||||||
expect(onSort).toBeCalledWith('bar', 'ascending');
|
expect(onSort).toBeCalledWith('bar', 'ascending');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Opening dropdown correctly updates state', () => {
|
|
||||||
const qsConfig = {
|
|
||||||
namespace: 'item',
|
|
||||||
defaultParams: { page: 1, page_size: 5, order_by: 'foo' },
|
|
||||||
integerFields: ['page', 'page_size'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
name: 'Foo',
|
|
||||||
key: 'foo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Bar',
|
|
||||||
key: 'bar',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Bakery',
|
|
||||||
key: 'bakery',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const onSort = jest.fn();
|
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<Sort qsConfig={qsConfig} columns={columns} onSort={onSort} />
|
|
||||||
).find('Sort');
|
|
||||||
expect(wrapper.state('isSortDropdownOpen')).toEqual(false);
|
|
||||||
wrapper.instance().handleDropdownToggle(true);
|
|
||||||
expect(wrapper.state('isSortDropdownOpen')).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('It displays correct sort icon', () => {
|
test('It displays correct sort icon', () => {
|
||||||
const forwardNumericIconSelector = 'SortNumericDownIcon';
|
const forwardNumericIconSelector = 'SortNumericDownIcon';
|
||||||
const reverseNumericIconSelector = 'SortNumericDownAltIcon';
|
const reverseNumericIconSelector = 'SortNumericDownAltIcon';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
import ActivityStream from './screens/ActivityStream';
|
||||||
import Applications from './screens/Application';
|
import Applications from './screens/Application';
|
||||||
import Credentials from './screens/Credential';
|
import Credentials from './screens/Credential';
|
||||||
import CredentialTypes from './screens/CredentialType';
|
import CredentialTypes from './screens/CredentialType';
|
||||||
@@ -44,6 +45,11 @@ function getRouteConfig(i18n) {
|
|||||||
path: '/schedules',
|
path: '/schedules',
|
||||||
screen: Schedules,
|
screen: Schedules,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: i18n._(t`Activity Stream`),
|
||||||
|
path: '/activity_stream',
|
||||||
|
screen: ActivityStream,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: i18n._(t`Workflow Approvals`),
|
title: i18n._(t`Workflow Approvals`),
|
||||||
path: '/workflow_approvals',
|
path: '/workflow_approvals',
|
||||||
|
|||||||
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 ApplicationsList from './ApplicationsList';
|
||||||
import ApplicationAdd from './ApplicationAdd';
|
import ApplicationAdd from './ApplicationAdd';
|
||||||
import Application from './Application';
|
import Application from './Application';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import { Detail, DetailList } from '../../components/DetailList';
|
import { Detail, DetailList } from '../../components/DetailList';
|
||||||
|
|
||||||
const ApplicationAlert = styled(Alert)`
|
const ApplicationAlert = styled(Alert)`
|
||||||
@@ -45,7 +45,10 @@ function Applications({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="o_auth2_application,o_auth2_access_token"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/applications/add">
|
<Route path="/applications/add">
|
||||||
<ApplicationAdd
|
<ApplicationAdd
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Applications from './Applications';
|
import Applications from './Applications';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Applications />', () => {
|
describe('<Applications />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Config } from '../../contexts/Config';
|
import { Config } from '../../contexts/Config';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import Credential from './Credential';
|
import Credential from './Credential';
|
||||||
import CredentialAdd from './CredentialAdd';
|
import CredentialAdd from './CredentialAdd';
|
||||||
import { CredentialList } from './CredentialList';
|
import { CredentialList } from './CredentialList';
|
||||||
@@ -34,7 +34,10 @@ function Credentials({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="credential"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/credentials/add">
|
<Route path="/credentials/add">
|
||||||
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
|
<Config>{({ me }) => <CredentialAdd me={me || {}} />}</Config>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
|
|||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import Credentials from './Credentials';
|
import Credentials from './Credentials';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Credentials />', () => {
|
describe('<Credentials />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
@@ -30,8 +34,8 @@ describe('<Credentials />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('Crumb').length).toBe(1);
|
expect(wrapper.find('Crumb').length).toBe(0);
|
||||||
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Credentials');
|
expect(wrapper.find('Title').text()).toBe('Credentials');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display create new credential breadcrumb heading', () => {
|
test('should display create new credential breadcrumb heading', () => {
|
||||||
@@ -51,8 +55,6 @@ describe('<Credentials />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('Crumb').length).toBe(2);
|
expect(wrapper.find('Crumb').length).toBe(2);
|
||||||
expect(wrapper.find('BreadcrumbHeading').text()).toBe(
|
expect(wrapper.find('Title').text()).toBe('Create New Credential');
|
||||||
'Create New Credential'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Route, Switch } from 'react-router-dom';
|
|||||||
import CredentialTypeAdd from './CredentialTypeAdd';
|
import CredentialTypeAdd from './CredentialTypeAdd';
|
||||||
import CredentialTypeList from './CredentialTypeList';
|
import CredentialTypeList from './CredentialTypeList';
|
||||||
import CredentialType from './CredentialType';
|
import CredentialType from './CredentialType';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
|
|
||||||
function CredentialTypes({ i18n }) {
|
function CredentialTypes({ i18n }) {
|
||||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||||
@@ -33,7 +33,10 @@ function CredentialTypes({ i18n }) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="credential_type"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/credential_types/add">
|
<Route path="/credential_types/add">
|
||||||
<CredentialTypeAdd />
|
<CredentialTypeAdd />
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import CredentialTypes from './CredentialTypes';
|
import CredentialTypes from './CredentialTypes';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<CredentialTypes/>', () => {
|
describe('<CredentialTypes/>', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
let pageSections;
|
let pageSections;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
|
|
||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
import { DashboardAPI } from '../../api';
|
import { DashboardAPI } from '../../api';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import JobList from '../../components/JobList';
|
import JobList from '../../components/JobList';
|
||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import LineChart from './shared/LineChart';
|
import LineChart from './shared/LineChart';
|
||||||
@@ -117,7 +117,10 @@ function Dashboard({ i18n }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
|
<ScreenHeader
|
||||||
|
streamType="all"
|
||||||
|
breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }}
|
||||||
|
/>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Counts>
|
<Counts>
|
||||||
<Count
|
<Count
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { DashboardAPI } from '../../api';
|
|||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
|
|
||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Dashboard />', () => {
|
describe('<Dashboard />', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
@@ -13,9 +13,14 @@ import PaginatedDataList, {
|
|||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import {
|
||||||
|
encodeQueryString,
|
||||||
|
getQSConfig,
|
||||||
|
parseQueryString,
|
||||||
|
} from '../../../util/qs';
|
||||||
|
|
||||||
import HostListItem from './HostListItem';
|
import HostListItem from './HostListItem';
|
||||||
|
import SmartInventoryButton from './SmartInventoryButton';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('host', {
|
const QS_CONFIG = getQSConfig('host', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -24,9 +29,21 @@ const QS_CONFIG = getQSConfig('host', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function HostList({ i18n }) {
|
function HostList({ i18n }) {
|
||||||
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
const parsedQueryStrings = parseQueryString(QS_CONFIG, location.search);
|
||||||
|
const nonDefaultSearchParams = {};
|
||||||
|
|
||||||
|
Object.keys(parsedQueryStrings).forEach(key => {
|
||||||
|
if (!QS_CONFIG.defaultParams[key]) {
|
||||||
|
nonDefaultSearchParams[key] = parsedQueryStrings[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasNonDefaultSearchParams =
|
||||||
|
Object.keys(nonDefaultSearchParams).length > 0;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
result: { hosts, count, actions, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -99,6 +116,14 @@ function HostList({ i18n }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSmartInventoryClick = () => {
|
||||||
|
history.push(
|
||||||
|
`/inventories/smart_inventory/add?host_filter=${encodeURIComponent(
|
||||||
|
encodeQueryString(nonDefaultSearchParams)
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const canAdd =
|
const canAdd =
|
||||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||||
|
|
||||||
@@ -157,6 +182,14 @@ function HostList({ i18n }) {
|
|||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Hosts`)}
|
pluralizedItemName={i18n._(t`Hosts`)}
|
||||||
/>,
|
/>,
|
||||||
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<SmartInventoryButton
|
||||||
|
isDisabled={!hasNonDefaultSearchParams}
|
||||||
|
onClick={() => handleSmartInventoryClick()}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import { HostsAPI } from '../../../api';
|
import { HostsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
@@ -257,7 +258,7 @@ describe('<HostList />', () => {
|
|||||||
expect(modal.prop('title')).toEqual('Error!');
|
expect(modal.prop('title')).toEqual('Error!');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show Add button according to permissions', async () => {
|
test('should show Add and Smart Inventory buttons according to permissions', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<HostList />);
|
wrapper = mountWithContexts(<HostList />);
|
||||||
@@ -265,9 +266,10 @@ describe('<HostList />', () => {
|
|||||||
await waitForLoaded(wrapper);
|
await waitForLoaded(wrapper);
|
||||||
|
|
||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
|
||||||
|
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should hide Add button according to permissions', async () => {
|
test('should hide Add and Smart Inventory buttons according to permissions', async () => {
|
||||||
HostsAPI.readOptions.mockResolvedValue({
|
HostsAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -282,5 +284,44 @@ describe('<HostList />', () => {
|
|||||||
await waitForLoaded(wrapper);
|
await waitForLoaded(wrapper);
|
||||||
|
|
||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||||
|
expect(wrapper.find('Button[aria-label="Smart Inventory"]').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Smart Inventory button should be disabled when no search params are present', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostList />);
|
||||||
|
});
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clicking Smart Inventory button should navigate to smart inventory form with correct query param', async () => {
|
||||||
|
let wrapper;
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/hosts?host.name__icontains=foo'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<HostList />, {
|
||||||
|
context: { router: { history } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForLoaded(wrapper);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').props().isDisabled
|
||||||
|
).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button[aria-label="Smart Inventory"]').simulate('click');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/inventories/smart_inventory/add'
|
||||||
|
);
|
||||||
|
expect(history.location.search).toEqual(
|
||||||
|
'?host_filter=name__icontains%3Dfoo'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Config } from '../../contexts/Config';
|
import { Config } from '../../contexts/Config';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
|
|
||||||
import HostList from './HostList';
|
import HostList from './HostList';
|
||||||
import HostAdd from './HostAdd';
|
import HostAdd from './HostAdd';
|
||||||
@@ -37,7 +37,7 @@ function Hosts({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader streamType="host" breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/hosts/add">
|
<Route path="/hosts/add">
|
||||||
<HostAdd />
|
<HostAdd />
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Hosts from './Hosts';
|
import Hosts from './Hosts';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Hosts />', () => {
|
describe('<Hosts />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<Hosts />);
|
mountWithContexts(<Hosts />);
|
||||||
@@ -27,7 +31,7 @@ describe('<Hosts />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
expect(wrapper.find('Title').length).toBe(1);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import InstanceGroup from './InstanceGroup';
|
|||||||
|
|
||||||
import ContainerGroupAdd from './ContainerGroupAdd';
|
import ContainerGroupAdd from './ContainerGroupAdd';
|
||||||
import ContainerGroup from './ContainerGroup';
|
import ContainerGroup from './ContainerGroup';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
|
|
||||||
function InstanceGroups({ i18n }) {
|
function InstanceGroups({ i18n }) {
|
||||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||||
@@ -54,7 +54,10 @@ function InstanceGroups({ i18n }) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="instance_group"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/instance_groups/container_group/add">
|
<Route path="/instance_groups/container_group/add">
|
||||||
<ContainerGroupAdd />
|
<ContainerGroupAdd />
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import InstanceGroups from './InstanceGroups';
|
import InstanceGroups from './InstanceGroups';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<InstanceGroups/>', () => {
|
describe('<InstanceGroups/>', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
let pageSections;
|
let pageSections;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import { Config } from '../../contexts/Config';
|
import { Config } from '../../contexts/Config';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
import { InventoryList } from './InventoryList';
|
import { InventoryList } from './InventoryList';
|
||||||
import Inventory from './Inventory';
|
import Inventory from './Inventory';
|
||||||
import SmartInventory from './SmartInventory';
|
import SmartInventory from './SmartInventory';
|
||||||
@@ -12,14 +12,34 @@ import InventoryAdd from './InventoryAdd';
|
|||||||
import SmartInventoryAdd from './SmartInventoryAdd';
|
import SmartInventoryAdd from './SmartInventoryAdd';
|
||||||
|
|
||||||
function Inventories({ i18n }) {
|
function Inventories({ i18n }) {
|
||||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
const initScreenHeader = useRef({
|
||||||
'/inventories': i18n._(t`Inventories`),
|
'/inventories': i18n._(t`Inventories`),
|
||||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
||||||
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
|
'/inventories/smart_inventory/add': i18n._(t`Create new smart inventory`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildBreadcrumbConfig = useCallback(
|
const [breadcrumbConfig, setScreenHeader] = useState(
|
||||||
(inventory, nested, schedule) => {
|
initScreenHeader.current
|
||||||
|
);
|
||||||
|
|
||||||
|
const [inventory, setInventory] = useState();
|
||||||
|
const [nestedObject, setNestedGroup] = useState();
|
||||||
|
const [schedule, setSchedule] = useState();
|
||||||
|
|
||||||
|
const setBreadcrumbConfig = useCallback(
|
||||||
|
(passedInventory, passedNestedObject, passedSchedule) => {
|
||||||
|
if (passedInventory && passedInventory.name !== inventory?.name) {
|
||||||
|
setInventory(passedInventory);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
passedNestedObject &&
|
||||||
|
passedNestedObject.name !== nestedObject?.name
|
||||||
|
) {
|
||||||
|
setNestedGroup(passedNestedObject);
|
||||||
|
}
|
||||||
|
if (passedSchedule && passedSchedule.name !== schedule?.name) {
|
||||||
|
setSchedule(passedSchedule);
|
||||||
|
}
|
||||||
if (!inventory) {
|
if (!inventory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -32,13 +52,8 @@ function Inventories({ i18n }) {
|
|||||||
const inventoryGroupsPath = `${inventoryPath}/groups`;
|
const inventoryGroupsPath = `${inventoryPath}/groups`;
|
||||||
const inventorySourcesPath = `${inventoryPath}/sources`;
|
const inventorySourcesPath = `${inventoryPath}/sources`;
|
||||||
|
|
||||||
setBreadcrumbConfig({
|
setScreenHeader({
|
||||||
'/inventories': i18n._(t`Inventories`),
|
...initScreenHeader.current,
|
||||||
'/inventories/inventory/add': i18n._(t`Create new inventory`),
|
|
||||||
'/inventories/smart_inventory/add': i18n._(
|
|
||||||
t`Create new smart inventory`
|
|
||||||
),
|
|
||||||
|
|
||||||
[inventoryPath]: `${inventory.name}`,
|
[inventoryPath]: `${inventory.name}`,
|
||||||
[`${inventoryPath}/access`]: i18n._(t`Access`),
|
[`${inventoryPath}/access`]: i18n._(t`Access`),
|
||||||
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
|
[`${inventoryPath}/completed_jobs`]: i18n._(t`Completed jobs`),
|
||||||
@@ -47,55 +62,74 @@ function Inventories({ i18n }) {
|
|||||||
|
|
||||||
[inventoryHostsPath]: i18n._(t`Hosts`),
|
[inventoryHostsPath]: i18n._(t`Hosts`),
|
||||||
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
|
[`${inventoryHostsPath}/add`]: i18n._(t`Create new host`),
|
||||||
[`${inventoryHostsPath}/${nested?.id}`]: `${nested?.name}`,
|
[`${inventoryHostsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||||
[`${inventoryHostsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
[`${inventoryHostsPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||||
[`${inventoryHostsPath}/${nested?.id}/details`]: i18n._(
|
t`Edit details`
|
||||||
|
),
|
||||||
|
[`${inventoryHostsPath}/${nestedObject?.id}/details`]: i18n._(
|
||||||
t`Host details`
|
t`Host details`
|
||||||
),
|
),
|
||||||
[`${inventoryHostsPath}/${nested?.id}/completed_jobs`]: i18n._(
|
[`${inventoryHostsPath}/${nestedObject?.id}/completed_jobs`]: i18n._(
|
||||||
t`Completed jobs`
|
t`Completed jobs`
|
||||||
),
|
),
|
||||||
[`${inventoryHostsPath}/${nested?.id}/facts`]: i18n._(t`Facts`),
|
[`${inventoryHostsPath}/${nestedObject?.id}/facts`]: i18n._(t`Facts`),
|
||||||
[`${inventoryHostsPath}/${nested?.id}/groups`]: i18n._(t`Groups`),
|
[`${inventoryHostsPath}/${nestedObject?.id}/groups`]: i18n._(t`Groups`),
|
||||||
|
|
||||||
[inventoryGroupsPath]: i18n._(t`Groups`),
|
[inventoryGroupsPath]: i18n._(t`Groups`),
|
||||||
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
|
[`${inventoryGroupsPath}/add`]: i18n._(t`Create new group`),
|
||||||
[`${inventoryGroupsPath}/${nested?.id}`]: `${nested?.name}`,
|
[`${inventoryGroupsPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
[`${inventoryGroupsPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/details`]: i18n._(
|
t`Edit details`
|
||||||
|
),
|
||||||
|
[`${inventoryGroupsPath}/${nestedObject?.id}/details`]: i18n._(
|
||||||
t`Group details`
|
t`Group details`
|
||||||
),
|
),
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts`]: i18n._(t`Hosts`),
|
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts`]: i18n._(
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/nested_hosts/add`]: i18n._(
|
t`Hosts`
|
||||||
|
),
|
||||||
|
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_hosts/add`]: i18n._(
|
||||||
t`Create new host`
|
t`Create new host`
|
||||||
),
|
),
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/nested_groups`]: i18n._(
|
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups`]: i18n._(
|
||||||
t`Groups`
|
t`Related Groups`
|
||||||
),
|
),
|
||||||
[`${inventoryGroupsPath}/${nested?.id}/nested_groups/add`]: i18n._(
|
[`${inventoryGroupsPath}/${nestedObject?.id}/nested_groups/add`]: i18n._(
|
||||||
t`Create new group`
|
t`Create new group`
|
||||||
),
|
),
|
||||||
|
|
||||||
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
[`${inventorySourcesPath}`]: i18n._(t`Sources`),
|
||||||
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
|
[`${inventorySourcesPath}/add`]: i18n._(t`Create new source`),
|
||||||
[`${inventorySourcesPath}/${nested?.id}`]: `${nested?.name}`,
|
[`${inventorySourcesPath}/${nestedObject?.id}`]: `${nestedObject?.name}`,
|
||||||
[`${inventorySourcesPath}/${nested?.id}/details`]: i18n._(t`Details`),
|
[`${inventorySourcesPath}/${nestedObject?.id}/details`]: i18n._(
|
||||||
[`${inventorySourcesPath}/${nested?.id}/edit`]: i18n._(t`Edit details`),
|
t`Details`
|
||||||
[`${inventorySourcesPath}/${nested?.id}/schedules`]: i18n._(
|
),
|
||||||
|
[`${inventorySourcesPath}/${nestedObject?.id}/edit`]: i18n._(
|
||||||
|
t`Edit details`
|
||||||
|
),
|
||||||
|
[`${inventorySourcesPath}/${nestedObject?.id}/schedules`]: i18n._(
|
||||||
t`Schedules`
|
t`Schedules`
|
||||||
),
|
),
|
||||||
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
|
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}`]: `${schedule?.name}`,
|
||||||
[`${inventorySourcesPath}/${nested?.id}/schedules/${schedule?.id}/details`]: i18n._(
|
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/add`]: i18n._(
|
||||||
|
t`Create New Schedule`
|
||||||
|
),
|
||||||
|
[`${inventorySourcesPath}/${nestedObject?.id}/schedules/${schedule?.id}/details`]: i18n._(
|
||||||
t`Schedule details`
|
t`Schedule details`
|
||||||
),
|
),
|
||||||
|
[`${inventorySourcesPath}/${nestedObject?.id}/notifications`]: i18n._(
|
||||||
|
t`Notifcations`
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[i18n]
|
[i18n, inventory, nestedObject, schedule]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="inventory"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/inventories/inventory/add">
|
<Route path="/inventories/inventory/add">
|
||||||
<InventoryAdd />
|
<InventoryAdd />
|
||||||
@@ -106,12 +140,12 @@ function Inventories({ i18n }) {
|
|||||||
<Route path="/inventories/inventory/:id">
|
<Route path="/inventories/inventory/:id">
|
||||||
<Config>
|
<Config>
|
||||||
{({ me }) => (
|
{({ me }) => (
|
||||||
<Inventory setBreadcrumb={buildBreadcrumbConfig} me={me || {}} />
|
<Inventory setBreadcrumb={setBreadcrumbConfig} me={me || {}} />
|
||||||
)}
|
)}
|
||||||
</Config>
|
</Config>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories/smart_inventory/:id">
|
<Route path="/inventories/smart_inventory/:id">
|
||||||
<SmartInventory setBreadcrumb={buildBreadcrumbConfig} />
|
<SmartInventory setBreadcrumb={setBreadcrumbConfig} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/inventories">
|
<Route path="/inventories">
|
||||||
<InventoryList />
|
<InventoryList />
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Inventories from './Inventories';
|
import Inventories from './Inventories';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Inventories />', () => {
|
describe('<Inventories />', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { func, shape, arrayOf } from 'prop-types';
|
import { func, shape, arrayOf } from 'prop-types';
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { InstanceGroup } from '../../../types';
|
import { InstanceGroup } from '../../../types';
|
||||||
@@ -14,6 +15,10 @@ import {
|
|||||||
FormColumnLayout,
|
FormColumnLayout,
|
||||||
FormFullWidthLayout,
|
FormFullWidthLayout,
|
||||||
} from '../../../components/FormLayout';
|
} from '../../../components/FormLayout';
|
||||||
|
import {
|
||||||
|
toHostFilter,
|
||||||
|
toSearchParams,
|
||||||
|
} from '../../../components/Lookup/shared/HostFilterUtils';
|
||||||
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
||||||
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
||||||
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
||||||
@@ -109,9 +114,17 @@ function SmartInventoryForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
submitError,
|
submitError,
|
||||||
}) {
|
}) {
|
||||||
|
const { search } = useLocation();
|
||||||
|
const queryParams = new URLSearchParams(search);
|
||||||
|
const hostFilterFromParams = queryParams.get('host_filter');
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
description: inventory.description || '',
|
description: inventory.description || '',
|
||||||
host_filter: inventory.host_filter || '',
|
host_filter:
|
||||||
|
inventory.host_filter ||
|
||||||
|
(hostFilterFromParams
|
||||||
|
? toHostFilter(toSearchParams(hostFilterFromParams))
|
||||||
|
: ''),
|
||||||
instance_groups: instanceGroups || [],
|
instance_groups: instanceGroups || [],
|
||||||
kind: 'smart',
|
kind: 'smart',
|
||||||
name: inventory.name || '',
|
name: inventory.name || '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -135,6 +136,29 @@ describe('<SmartInventoryForm />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should pre-fill the host filter when query param present and not editing', async () => {
|
||||||
|
let wrapper;
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: [
|
||||||
|
'/inventories/smart_inventory/add?host_filter=name__icontains%3Dfoo',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} />,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const nameChipGroup = wrapper.find(
|
||||||
|
'HostFilterLookup ChipGroup[categoryName="Name"]'
|
||||||
|
);
|
||||||
|
expect(nameChipGroup.find('Chip').length).toBe(1);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
test('should throw content error when option request fails', async () => {
|
test('should throw content error when option request fails', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Job from './Jobs';
|
import Job from './Jobs';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Job />', () => {
|
describe('<Job />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<Job />);
|
mountWithContexts(<Job />);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
import React, { Component, Fragment } from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { I18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
@@ -518,7 +518,7 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { job, i18n } = this.props;
|
const { job } = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contentError,
|
contentError,
|
||||||
@@ -596,15 +596,21 @@ class JobOutput extends Component {
|
|||||||
</OutputWrapper>
|
</OutputWrapper>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
{deletionError && (
|
{deletionError && (
|
||||||
<AlertModal
|
<>
|
||||||
isOpen={deletionError}
|
<I18n>
|
||||||
variant="danger"
|
{({ i18n }) => (
|
||||||
onClose={() => this.setState({ deletionError: null })}
|
<AlertModal
|
||||||
title={i18n._(t`Job Delete Error`)}
|
isOpen={deletionError}
|
||||||
label={i18n._(t`Job Delete Error`)}
|
variant="danger"
|
||||||
>
|
onClose={() => this.setState({ deletionError: null })}
|
||||||
<ErrorDetail error={deletionError} />
|
title={i18n._(t`Job Delete Error`)}
|
||||||
</AlertModal>
|
label={i18n._(t`Job Delete Error`)}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={deletionError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</I18n>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
@@ -612,4 +618,4 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { JobOutput as _JobOutput };
|
export { JobOutput as _JobOutput };
|
||||||
export default withI18n()(withRouter(JobOutput));
|
export default withRouter(JobOutput);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
|
|||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
import { UnifiedJobsAPI } from '../../api';
|
import { UnifiedJobsAPI } from '../../api';
|
||||||
import ContentError from '../../components/ContentError';
|
import ContentError from '../../components/ContentError';
|
||||||
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||||
|
|
||||||
const NOT_FOUND = 'not found';
|
const NOT_FOUND = 'not found';
|
||||||
@@ -46,8 +47,13 @@ function JobTypeRedirect({ id, path, view, i18n }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isLoading || !job?.id) {
|
if (isLoading || !job?.id) {
|
||||||
// TODO show loading state
|
return (
|
||||||
return <div>Loading...</div>;
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<ContentLoading />
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||||
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;
|
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Route, Switch, useParams, useRouteMatch } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { PageSection } from '@patternfly/react-core';
|
import { PageSection } from '@patternfly/react-core';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
import Job from './Job';
|
import Job from './Job';
|
||||||
import JobTypeRedirect from './JobTypeRedirect';
|
import JobTypeRedirect from './JobTypeRedirect';
|
||||||
import JobList from '../../components/JobList';
|
import JobList from '../../components/JobList';
|
||||||
@@ -40,7 +40,7 @@ function Jobs({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader streamType="job" breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path={match.path}>
|
<Route exact path={match.path}>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Jobs from './Jobs';
|
import Jobs from './Jobs';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Jobs />', () => {
|
describe('<Jobs />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<Jobs />);
|
mountWithContexts(<Jobs />);
|
||||||
@@ -27,7 +31,7 @@ describe('<Jobs />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
expect(wrapper.find('Title').length).toBe(1);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import React, { Fragment } from 'react';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
|
|
||||||
function ManagementJobs({ i18n }) {
|
function ManagementJobs({ i18n }) {
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Breadcrumbs
|
<ScreenHeader
|
||||||
|
streamType="none"
|
||||||
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
|
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import ManagementJobs from './ManagementJobs';
|
import ManagementJobs from './ManagementJobs';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<ManagementJobs />', () => {
|
describe('<ManagementJobs />', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
|
|
||||||
@@ -17,6 +21,6 @@ describe('<ManagementJobs />', () => {
|
|||||||
|
|
||||||
test('initially renders without crashing', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(pageWrapper.length).toBe(1);
|
expect(pageWrapper.length).toBe(1);
|
||||||
expect(pageWrapper.find('Breadcrumbs').length).toBe(1);
|
expect(pageWrapper.find('ScreenHeader').length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
|||||||
import NotificationTemplateList from './NotificationTemplateList';
|
import NotificationTemplateList from './NotificationTemplateList';
|
||||||
import NotificationTemplateAdd from './NotificationTemplateAdd';
|
import NotificationTemplateAdd from './NotificationTemplateAdd';
|
||||||
import NotificationTemplate from './NotificationTemplate';
|
import NotificationTemplate from './NotificationTemplate';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
|
|
||||||
function NotificationTemplates({ i18n }) {
|
function NotificationTemplates({ i18n }) {
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
@@ -32,7 +32,10 @@ function NotificationTemplates({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="notification_template"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${match.url}/add`}>
|
<Route path={`${match.url}/add`}>
|
||||||
<NotificationTemplateAdd />
|
<NotificationTemplateAdd />
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import React from 'react';
|
|||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import NotificationTemplates from './NotificationTemplates';
|
import NotificationTemplates from './NotificationTemplates';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<NotificationTemplates />', () => {
|
describe('<NotificationTemplates />', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
let pageSections;
|
let pageSections;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Config } from '../../contexts/Config';
|
import { Config } from '../../contexts/Config';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
|
|
||||||
import OrganizationsList from './OrganizationList/OrganizationList';
|
import OrganizationsList from './OrganizationList/OrganizationList';
|
||||||
import OrganizationAdd from './OrganizationAdd/OrganizationAdd';
|
import OrganizationAdd from './OrganizationAdd/OrganizationAdd';
|
||||||
@@ -42,7 +42,10 @@ function Organizations({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader
|
||||||
|
streamType="organization"
|
||||||
|
breadcrumbConfig={breadcrumbConfig}
|
||||||
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${match.path}/add`}>
|
<Route path={`${match.path}/add`}>
|
||||||
<OrganizationAdd />
|
<OrganizationAdd />
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
import Organizations from './Organizations';
|
import Organizations from './Organizations';
|
||||||
|
|
||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Organizations />', () => {
|
describe('<Organizations />', () => {
|
||||||
test('initially renders succesfully', async () => {
|
test('initially renders succesfully', async () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader/ScreenHeader';
|
||||||
|
|
||||||
import ProjectsList from './ProjectList/ProjectList';
|
import ProjectsList from './ProjectList/ProjectList';
|
||||||
import ProjectAdd from './ProjectAdd/ProjectAdd';
|
import ProjectAdd from './ProjectAdd/ProjectAdd';
|
||||||
@@ -45,7 +45,7 @@ function Projects({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader streamType="project" breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/projects/add">
|
<Route path="/projects/add">
|
||||||
<ProjectAdd />
|
<ProjectAdd />
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
|||||||
|
|
||||||
import Projects from './Projects';
|
import Projects from './Projects';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Projects />', () => {
|
describe('<Projects />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<Projects />);
|
mountWithContexts(<Projects />);
|
||||||
@@ -27,7 +31,7 @@ describe('<Projects />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
|
expect(wrapper.find('Title').length).toBe(1);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -310,7 +310,17 @@ function ProjectForm({ i18n, project, submitError, ...props }) {
|
|||||||
const { summary_fields = {} } = project;
|
const { summary_fields = {} } = project;
|
||||||
const [contentError, setContentError] = useState(null);
|
const [contentError, setContentError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [scmSubFormState, setScmSubFormState] = useState(null);
|
const [scmSubFormState, setScmSubFormState] = useState({
|
||||||
|
scm_url: '',
|
||||||
|
scm_branch: '',
|
||||||
|
scm_refspec: '',
|
||||||
|
credential: '',
|
||||||
|
scm_clean: false,
|
||||||
|
scm_delete_on_update: false,
|
||||||
|
scm_update_on_launch: false,
|
||||||
|
allow_override: false,
|
||||||
|
scm_update_cache_timeout: 0,
|
||||||
|
});
|
||||||
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
const [scmTypeOptions, setScmTypeOptions] = useState(null);
|
||||||
const [credentials, setCredentials] = useState({
|
const [credentials, setCredentials] = useState({
|
||||||
scm: { typeId: null, value: null },
|
scm: { typeId: null, value: null },
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { SyncIcon } from '@patternfly/react-icons';
|
|||||||
import { number } from 'prop-types';
|
import { number } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
|
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import { ProjectsAPI } from '../../../api';
|
import { ProjectsAPI } from '../../../api';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { PageSection, Card } from '@patternfly/react-core';
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import { ScheduleList } from '../../components/Schedule';
|
import { ScheduleList } from '../../components/Schedule';
|
||||||
import { SchedulesAPI } from '../../api';
|
import { SchedulesAPI } from '../../api';
|
||||||
|
|
||||||
@@ -19,7 +19,8 @@ function AllSchedules({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs
|
<ScreenHeader
|
||||||
|
streamType="schedule"
|
||||||
breadcrumbConfig={{
|
breadcrumbConfig={{
|
||||||
'/schedules': i18n._(t`Schedules`),
|
'/schedules': i18n._(t`Schedules`),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { createMemoryHistory } from 'history';
|
|||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import AllSchedules from './AllSchedules';
|
import AllSchedules from './AllSchedules';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<AllSchedules />', () => {
|
describe('<AllSchedules />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
@@ -30,7 +34,6 @@ describe('<AllSchedules />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(wrapper.find('Crumb').length).toBe(1);
|
expect(wrapper.find('Title').text()).toBe('Schedules');
|
||||||
expect(wrapper.find('BreadcrumbHeading').text()).toBe('Schedules');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import React from 'react';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import RADIUS from './RADIUS';
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
|
import RADIUS from './RADIUS';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
data: {},
|
data: {
|
||||||
|
RADIUS_SERVER: 'radius.example.org',
|
||||||
|
RADIUS_PORT: 1812,
|
||||||
|
RADIUS_SECRET: '$encrypted$',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<RADIUS />', () => {
|
describe('<RADIUS />', () => {
|
||||||
@@ -23,9 +29,14 @@ describe('<RADIUS />', () => {
|
|||||||
initialEntries: ['/settings/radius/details'],
|
initialEntries: ['/settings/radius/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<RADIUS />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<RADIUS />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('RADIUSDetail').length).toBe(1);
|
expect(wrapper.find('RADIUSDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -35,9 +46,14 @@ describe('<RADIUS />', () => {
|
|||||||
initialEntries: ['/settings/radius/edit'],
|
initialEntries: ['/settings/radius/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<RADIUS />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<RADIUS />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('RADIUSEdit').length).toBe(1);
|
expect(wrapper.find('RADIUSEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,113 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import { EncryptedField, InputField } from '../../shared/SharedFields';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function RADIUSEdit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const { isLoading, error, request: fetchRadius, result: radius } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('radius');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRadius();
|
||||||
|
}, [fetchRadius]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/radius/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(radius).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/radius/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
function RADIUSEdit({ i18n }) {
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && radius && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik initialValues={initialValues(radius)} onSubmit={handleSubmit}>
|
||||||
component={Link}
|
{formik => (
|
||||||
to="/settings/radius/details"
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
>
|
<FormColumnLayout>
|
||||||
{i18n._(t`Cancel`)}
|
<InputField
|
||||||
</Button>
|
name="RADIUS_SERVER"
|
||||||
</CardActionsRow>
|
config={radius.RADIUS_SERVER}
|
||||||
|
/>
|
||||||
|
<InputField name="RADIUS_PORT" config={radius.RADIUS_PORT} />
|
||||||
|
<EncryptedField
|
||||||
|
name="RADIUS_SECRET"
|
||||||
|
config={radius.RADIUS_SECRET}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(RADIUSEdit);
|
export default RADIUSEdit;
|
||||||
|
|||||||
@@ -1,16 +1,149 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||||
|
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
import RADIUSEdit from './RADIUSEdit';
|
import RADIUSEdit from './RADIUSEdit';
|
||||||
|
|
||||||
|
jest.mock('../../../../api/models/Settings');
|
||||||
|
SettingsAPI.updateAll.mockResolvedValue({});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
RADIUS_SERVER: 'radius.mock.org',
|
||||||
|
RADIUS_PORT: 1812,
|
||||||
|
RADIUS_SECRET: '$encrypted$',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('<RADIUSEdit />', () => {
|
describe('<RADIUSEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<RADIUSEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/radius/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<RADIUSEdit />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('RADIUSEdit').length).toBe(1);
|
expect(wrapper.find('RADIUSEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should display expected form fields', async () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="RADIUS Server"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="RADIUS Port"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="RADIUS Secret"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send default values to api on form revert all', async () => {
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Revert all to default"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
RADIUS_SERVER: '',
|
||||||
|
RADIUS_PORT: 1812,
|
||||||
|
RADIUS_SECRET: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send request to api on form submission', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('input#RADIUS_SERVER').simulate('change', {
|
||||||
|
target: { value: 'radius.new_mock.org', name: 'RADIUS_SERVER' },
|
||||||
|
});
|
||||||
|
wrapper
|
||||||
|
.find('FormGroup[fieldId="RADIUS_SECRET"] button[aria-label="Revert"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
RADIUS_SERVER: 'radius.new_mock.org',
|
||||||
|
RADIUS_PORT: 1812,
|
||||||
|
RADIUS_SECRET: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to radius detail on successful submission', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/radius/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to radius detail when cancel is clicked', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/radius/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display error message on unsuccessful submission', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display ContentError on throw', async () => {
|
||||||
|
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<RADIUSEdit />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { PageSection, Card } from '@patternfly/react-core';
|
import { PageSection, Card } from '@patternfly/react-core';
|
||||||
import ContentError from '../../components/ContentError';
|
import ContentError from '../../components/ContentError';
|
||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import ActivityStream from './ActivityStream';
|
import ActivityStream from './ActivityStream';
|
||||||
import AzureAD from './AzureAD';
|
import AzureAD from './AzureAD';
|
||||||
import GitHub from './GitHub';
|
import GitHub from './GitHub';
|
||||||
@@ -129,7 +129,7 @@ function Settings({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsProvider value={result}>
|
<SettingsProvider value={result}>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader streamType="setting" breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/settings/activity_stream">
|
<Route path="/settings/activity_stream">
|
||||||
<ActivityStream />
|
<ActivityStream />
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ jest.mock('../../api/models/Settings');
|
|||||||
SettingsAPI.readAllOptions.mockResolvedValue({
|
SettingsAPI.readAllOptions.mockResolvedValue({
|
||||||
data: mockAllOptions,
|
data: mockAllOptions,
|
||||||
});
|
});
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('<Settings />', () => {
|
describe('<Settings />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import React from 'react';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
import TACACS from './TACACS';
|
import TACACS from './TACACS';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
data: {},
|
data: {
|
||||||
|
TACACSPLUS_HOST: 'mockhost',
|
||||||
|
TACACSPLUS_PORT: 49,
|
||||||
|
TACACSPLUS_SECRET: '$encrypted$',
|
||||||
|
TACACSPLUS_SESSION_TIMEOUT: 5,
|
||||||
|
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<TACACS />', () => {
|
describe('<TACACS />', () => {
|
||||||
@@ -23,9 +31,14 @@ describe('<TACACS />', () => {
|
|||||||
initialEntries: ['/settings/tacacs/details'],
|
initialEntries: ['/settings/tacacs/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<TACACS />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<TACACS />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('TACACSDetail').length).toBe(1);
|
expect(wrapper.find('TACACSDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -35,9 +48,14 @@ describe('<TACACS />', () => {
|
|||||||
initialEntries: ['/settings/tacacs/edit'],
|
initialEntries: ['/settings/tacacs/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<TACACS />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<TACACS />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('TACACSEdit').length).toBe(1);
|
expect(wrapper.find('TACACSEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,130 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import {
|
||||||
|
ChoiceField,
|
||||||
|
EncryptedField,
|
||||||
|
InputField,
|
||||||
|
} from '../../shared/SharedFields';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function TACACSEdit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const { isLoading, error, request: fetchTACACS, result: tacacs } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('tacacsplus');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTACACS();
|
||||||
|
}, [fetchTACACS]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/tacacs/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(tacacs).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/tacacs/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
function TACACSEdit({ i18n }) {
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && tacacs && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik initialValues={initialValues(tacacs)} onSubmit={handleSubmit}>
|
||||||
component={Link}
|
{formik => (
|
||||||
to="/settings/tacacs/details"
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
>
|
<FormColumnLayout>
|
||||||
{i18n._(t`Cancel`)}
|
<InputField
|
||||||
</Button>
|
name="TACACSPLUS_HOST"
|
||||||
</CardActionsRow>
|
config={tacacs.TACACSPLUS_HOST}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
name="TACACSPLUS_PORT"
|
||||||
|
config={tacacs.TACACSPLUS_PORT}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<EncryptedField
|
||||||
|
name="TACACSPLUS_SECRET"
|
||||||
|
config={tacacs.TACACSPLUS_SECRET}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
name="TACACSPLUS_SESSION_TIMEOUT"
|
||||||
|
config={tacacs.TACACSPLUS_SESSION_TIMEOUT}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<ChoiceField
|
||||||
|
name="TACACSPLUS_AUTH_PROTOCOL"
|
||||||
|
config={tacacs.TACACSPLUS_AUTH_PROTOCOL}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(TACACSEdit);
|
export default TACACSEdit;
|
||||||
|
|||||||
@@ -1,16 +1,166 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||||
|
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
import TACACSEdit from './TACACSEdit';
|
import TACACSEdit from './TACACSEdit';
|
||||||
|
|
||||||
|
jest.mock('../../../../api/models/Settings');
|
||||||
|
SettingsAPI.updateAll.mockResolvedValue({});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
TACACSPLUS_HOST: 'mockhost',
|
||||||
|
TACACSPLUS_PORT: 49,
|
||||||
|
TACACSPLUS_SECRET: '$encrypted$',
|
||||||
|
TACACSPLUS_SESSION_TIMEOUT: 123,
|
||||||
|
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('<TACACSEdit />', () => {
|
describe('<TACACSEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<TACACSEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/tacacs/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<TACACSEdit />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('TACACSEdit').length).toBe(1);
|
expect(wrapper.find('TACACSEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should display expected form fields', async () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="TACACS+ Server"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="TACACS+ Port"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="TACACS+ Secret"]').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="TACACS+ Auth Session Timeout"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="TACACS+ Authentication Protocol"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send default values to api on form revert all', async () => {
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Revert all to default"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
TACACSPLUS_HOST: '',
|
||||||
|
TACACSPLUS_PORT: 49,
|
||||||
|
TACACSPLUS_SECRET: '',
|
||||||
|
TACACSPLUS_SESSION_TIMEOUT: 5,
|
||||||
|
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send request to api on form submission', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('input#TACACSPLUS_HOST').simulate('change', {
|
||||||
|
target: { value: 'new_host', name: 'TACACSPLUS_HOST' },
|
||||||
|
});
|
||||||
|
wrapper.find('input#TACACSPLUS_PORT').simulate('change', {
|
||||||
|
target: { value: 999, name: 'TACACSPLUS_PORT' },
|
||||||
|
});
|
||||||
|
wrapper
|
||||||
|
.find(
|
||||||
|
'FormGroup[fieldId="TACACSPLUS_SECRET"] button[aria-label="Revert"]'
|
||||||
|
)
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
TACACSPLUS_HOST: 'new_host',
|
||||||
|
TACACSPLUS_PORT: 999,
|
||||||
|
TACACSPLUS_SECRET: '',
|
||||||
|
TACACSPLUS_SESSION_TIMEOUT: 123,
|
||||||
|
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to tacacs detail on successful submission', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/tacacs/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to tacacs detail when cancel is clicked', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/tacacs/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display error message on unsuccessful submission', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display ContentError on throw', async () => {
|
||||||
|
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<TACACSEdit />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,71 +27,68 @@ const DataListAction = styled(_DataListAction)`
|
|||||||
grid-template-columns: 40px;
|
grid-template-columns: 40px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class TeamListItem extends React.Component {
|
function TeamListItem({ team, isSelected, onSelect, detailUrl, i18n }) {
|
||||||
static propTypes = {
|
TeamListItem.propTypes = {
|
||||||
team: Team.isRequired,
|
team: Team.isRequired,
|
||||||
detailUrl: string.isRequired,
|
detailUrl: string.isRequired,
|
||||||
isSelected: bool.isRequired,
|
isSelected: bool.isRequired,
|
||||||
onSelect: func.isRequired,
|
onSelect: func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const labelId = `check-action-${team.id}`;
|
||||||
const { team, isSelected, onSelect, detailUrl, i18n } = this.props;
|
|
||||||
const labelId = `check-action-${team.id}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}>
|
<DataListItem key={team.id} aria-labelledby={labelId} id={`${team.id}`}>
|
||||||
<DataListItemRow>
|
<DataListItemRow>
|
||||||
<DataListCheck
|
<DataListCheck
|
||||||
id={`select-team-${team.id}`}
|
id={`select-team-${team.id}`}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
<DataListItemCells
|
<DataListItemCells
|
||||||
dataListCells={[
|
dataListCells={[
|
||||||
<DataListCell key="name">
|
<DataListCell key="name">
|
||||||
<Link id={labelId} to={`${detailUrl}`}>
|
<Link id={labelId} to={`${detailUrl}`}>
|
||||||
<b>{team.name}</b>
|
<b>{team.name}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
<DataListCell key="organization">
|
<DataListCell key="organization">
|
||||||
{team.summary_fields.organization && (
|
{team.summary_fields.organization && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<b>{i18n._(t`Organization`)}</b>{' '}
|
<b>{i18n._(t`Organization`)}</b>{' '}
|
||||||
<Link
|
<Link
|
||||||
to={`/organizations/${team.summary_fields.organization.id}/details`}
|
to={`/organizations/${team.summary_fields.organization.id}/details`}
|
||||||
>
|
>
|
||||||
<b>{team.summary_fields.organization.name}</b>
|
<b>{team.summary_fields.organization.name}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
</DataListCell>,
|
</DataListCell>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label="actions"
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
{team.summary_fields.user_capabilities.edit ? (
|
{team.summary_fields.user_capabilities.edit ? (
|
||||||
<Tooltip content={i18n._(t`Edit Team`)} position="top">
|
<Tooltip content={i18n._(t`Edit Team`)} position="top">
|
||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Edit Team`)}
|
aria-label={i18n._(t`Edit Team`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/teams/${team.id}/edit`}
|
to={`/teams/${team.id}/edit`}
|
||||||
>
|
>
|
||||||
<PencilAltIcon />
|
<PencilAltIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
''
|
''
|
||||||
)}
|
)}
|
||||||
</DataListAction>
|
</DataListAction>
|
||||||
</DataListItemRow>
|
</DataListItemRow>
|
||||||
</DataListItem>
|
</DataListItem>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export default withI18n()(TeamListItem);
|
export default withI18n()(TeamListItem);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import { Config } from '../../contexts/Config';
|
import { Config } from '../../contexts/Config';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
import TeamList from './TeamList';
|
import TeamList from './TeamList';
|
||||||
import TeamAdd from './TeamAdd';
|
import TeamAdd from './TeamAdd';
|
||||||
import Team from './Team';
|
import Team from './Team';
|
||||||
@@ -29,6 +29,7 @@ function Teams({ i18n }) {
|
|||||||
[`/teams/${team.id}/details`]: i18n._(t`Details`),
|
[`/teams/${team.id}/details`]: i18n._(t`Details`),
|
||||||
[`/teams/${team.id}/users`]: i18n._(t`Users`),
|
[`/teams/${team.id}/users`]: i18n._(t`Users`),
|
||||||
[`/teams/${team.id}/access`]: i18n._(t`Access`),
|
[`/teams/${team.id}/access`]: i18n._(t`Access`),
|
||||||
|
[`/teams/${team.id}/roles`]: i18n._(t`Roles`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[i18n]
|
[i18n]
|
||||||
@@ -36,7 +37,7 @@ function Teams({ i18n }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<ScreenHeader streamType="team" breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/teams/add">
|
<Route path="/teams/add">
|
||||||
<TeamAdd />
|
<TeamAdd />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user