Merge pull request #9011 from AlexSCorey/PreLingUI2

Updates files to pre lingUI upgrade work

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-01-21 20:30:18 +00:00
committed by GitHub
15 changed files with 819 additions and 933 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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