Skip to content

Commit c74d654

Browse files
committed
Fixes #39437 - Apply taxonomies to UI bulk actions
Before this patch, the Foreman UI did not send any information about taxonomies on the 'All Hosts' page for bulk actions. This could cause bulk actions to be applied to different hosts than the ones that were actually selected in the UI because the session in the backend would calculate the hosts independent from UI input. Assisted-by: OpenAI Codex (cherry picked from commit 34ee2c5)
1 parent 924f09d commit c74d654

20 files changed

Lines changed: 360 additions & 54 deletions

File tree

app/controllers/api/v2/hosts_bulk_actions_controller.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ class HostsBulkActionsController < V2::BaseController
99
before_action :validate_power_action, :only => [:change_power_state]
1010

1111
def_param_group :bulk_host_ids do
12-
param :organization_id, :number, :required => true, :desc => N_("ID of the organization")
1312
param :included, Hash, :desc => N_("Hosts to include in the action"), :required => true, :action_aware => true do
1413
param :search, String, :required => false, :desc => N_("Search string describing which hosts to perform the action on")
1514
param :ids, Array, :required => false, :desc => N_("List of host ids to perform the action on")

app/controllers/concerns/api/v2/bulk_hosts_extension.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
module Api::V2::BulkHostsExtension
22
extend ActiveSupport::Concern
33

4-
def bulk_hosts_relation(permission, org)
4+
def bulk_hosts_relation(permission, org, location)
55
relation = ::Host::Managed.authorized(permission)
66
relation = relation.where(organization: org) if org
7+
relation = relation.where(location: location) if location
78
relation
89
end
910

@@ -18,7 +19,8 @@ def find_bulk_hosts(permission, bulk_params, restrict_to = nil)
1819
end
1920

2021
find_organization
21-
@hosts = bulk_hosts_relation(permission, @organization)
22+
find_location
23+
@hosts = bulk_hosts_relation(permission, @organization, @location)
2224

2325
if bulk_params[:included][:ids].present?
2426
@hosts = @hosts.where(id: bulk_params[:included][:ids])
@@ -42,4 +44,8 @@ def find_bulk_hosts(permission, bulk_params, restrict_to = nil)
4244
def find_organization
4345
@organization ||= Organization.find_by_id(params[:organization_id])
4446
end
47+
48+
def find_location
49+
@location ||= Location.find_by_id(params[:location_id])
50+
end
4551
end

test/controllers/api/v2/hosts_bulk_actions_controller_test.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def setup
1717
def valid_bulk_params(host_ids = @host_ids)
1818
{
1919
:organization_id => @organization.id,
20+
:location_id => @location.id,
2021
:included => {
2122
:ids => host_ids,
2223
},
@@ -107,6 +108,33 @@ def valid_power_params(host_ids = @host_ids, action = 'start')
107108
end
108109
end
109110

111+
test "should scope searched hosts by organization and location" do
112+
other_location = FactoryBot.create(:location)
113+
other_host = FactoryBot.create(:host, :managed, :organization => @organization, :location => other_location)
114+
115+
put :change_owner, params: {
116+
:organization_id => @organization.id,
117+
:location_id => @location.id,
118+
:included => {
119+
:search => 'name ~ *',
120+
},
121+
:excluded => {
122+
:ids => [],
123+
},
124+
:owner_id => @user.id_and_type,
125+
}
126+
127+
assert_response :success
128+
129+
[@host1, @host2, @host3].each do |host|
130+
host.reload
131+
assert_equal @user.id_and_type, host.is_owned_by
132+
end
133+
134+
other_host.reload
135+
refute_equal @user.id_and_type, other_host.is_owned_by
136+
end
137+
110138
context "change_power_state" do
111139
test "successfully changes power state for all hosts" do
112140
Host.any_instance.stubs(:supports_power?).returns(true)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { bulkDeleteHosts } from '../bulkDelete';
2+
import { openConfirmModal } from '../../../ConfirmModal';
3+
import { APIActions } from '../../../../redux/API';
4+
import { visit } from '../../../../common/helpers';
5+
6+
jest.mock('../../../ConfirmModal', () => ({
7+
openConfirmModal: jest.fn(payload => ({
8+
type: 'OPEN_CONFIRM_MODAL',
9+
payload,
10+
})),
11+
}));
12+
13+
jest.mock('../../../../redux/API', () => ({
14+
APIActions: {
15+
delete: jest.fn(params => ({
16+
type: 'API_DELETE',
17+
params,
18+
})),
19+
},
20+
}));
21+
22+
jest.mock('../../../../common/helpers', () => ({
23+
foremanUrl: jest.fn(path => path),
24+
visit: jest.fn(),
25+
}));
26+
27+
describe('bulkDeleteHosts', () => {
28+
const dispatch = jest.fn(action => {
29+
if (typeof action === 'function') {
30+
return action(dispatch);
31+
}
32+
return action;
33+
});
34+
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
const bulkParams = 'id ^ (1,2,3)';
40+
const organizationId = 1;
41+
const locationId = 2;
42+
const selectedCount = 3;
43+
const destroyVmOnHostDelete = true;
44+
45+
it('dispatches openConfirmModal with correct parameters', () => {
46+
bulkDeleteHosts({
47+
bulkParams,
48+
organizationId,
49+
locationId,
50+
selectedCount,
51+
destroyVmOnHostDelete,
52+
})(dispatch);
53+
54+
expect(openConfirmModal).toHaveBeenCalledTimes(1);
55+
const modalPayload = openConfirmModal.mock.calls[0][0];
56+
57+
expect(modalPayload.isWarning).toBe(true);
58+
expect(modalPayload.isDireWarning).toBe(true);
59+
expect(modalPayload.id).toBe('bulk-delete-hosts-modal');
60+
expect(modalPayload.confirmButtonText).toBe('Delete');
61+
expect(typeof modalPayload.onConfirm).toBe('function');
62+
});
63+
64+
describe('onConfirm callback', () => {
65+
it('calls visit with /new/hosts when onDeleteSuccess is not provided', () => {
66+
bulkDeleteHosts({
67+
bulkParams,
68+
organizationId,
69+
locationId,
70+
selectedCount,
71+
destroyVmOnHostDelete,
72+
})(dispatch);
73+
74+
const modalPayload = openConfirmModal.mock.calls[0][0];
75+
modalPayload.onConfirm();
76+
77+
expect(APIActions.delete).toHaveBeenCalledTimes(1);
78+
const deleteParams = APIActions.delete.mock.calls[0][0];
79+
const requestUrl = new URL(deleteParams.url, 'https://example.test');
80+
81+
expect(requestUrl.pathname).toBe('/api/v2/hosts/bulk');
82+
expect(requestUrl.searchParams.get('search')).toBe(bulkParams);
83+
expect(requestUrl.searchParams.get('organization_id')).toBe(
84+
String(organizationId)
85+
);
86+
expect(requestUrl.searchParams.get('location_id')).toBe(
87+
String(locationId)
88+
);
89+
expect(deleteParams.key).toBe('BULK-HOSTS-DELETE');
90+
expect(typeof deleteParams.successToast).toBe('function');
91+
expect(typeof deleteParams.errorToast).toBe('function');
92+
expect(typeof deleteParams.handleSuccess).toBe('function');
93+
94+
deleteParams.handleSuccess();
95+
expect(visit).toHaveBeenCalledWith('/new/hosts');
96+
});
97+
98+
it('calls onDeleteSuccess callback when provided instead of visit', () => {
99+
const onDeleteSuccess = jest.fn();
100+
101+
bulkDeleteHosts({
102+
bulkParams,
103+
organizationId,
104+
locationId,
105+
selectedCount,
106+
destroyVmOnHostDelete,
107+
onDeleteSuccess,
108+
})(dispatch);
109+
110+
const modalPayload = openConfirmModal.mock.calls[0][0];
111+
modalPayload.onConfirm();
112+
113+
const deleteParams = APIActions.delete.mock.calls[0][0];
114+
deleteParams.handleSuccess();
115+
116+
expect(onDeleteSuccess).toHaveBeenCalledTimes(1);
117+
expect(visit).not.toHaveBeenCalled();
118+
});
119+
});
120+
121+
describe('successToast', () => {
122+
it('returns formatted success message with host count', () => {
123+
bulkDeleteHosts({
124+
bulkParams,
125+
organizationId,
126+
locationId,
127+
selectedCount,
128+
destroyVmOnHostDelete,
129+
})(dispatch);
130+
131+
const modalPayload = openConfirmModal.mock.calls[0][0];
132+
modalPayload.onConfirm();
133+
134+
const deleteParams = APIActions.delete.mock.calls[0][0];
135+
const toastMessage = deleteParams.successToast();
136+
137+
expect(toastMessage).toContain(String(selectedCount));
138+
});
139+
});
140+
141+
describe('errorToast', () => {
142+
it('returns error message from response', () => {
143+
bulkDeleteHosts({
144+
bulkParams,
145+
organizationId,
146+
locationId,
147+
selectedCount,
148+
destroyVmOnHostDelete,
149+
})(dispatch);
150+
151+
const modalPayload = openConfirmModal.mock.calls[0][0];
152+
modalPayload.onConfirm();
153+
154+
const deleteParams = APIActions.delete.mock.calls[0][0];
155+
const errorMessage = 'Bulk delete failed';
156+
const toastMessage = deleteParams.errorToast({ message: errorMessage });
157+
158+
expect(toastMessage).toBe(errorMessage);
159+
});
160+
});
161+
});

webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/BulkAssignTaxonomyModal.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
API_REQUEST_KEY,
3939
} from '../../../../routes/Hosts/constants';
4040
import TaxonomySelect from './TaxonomySelect';
41+
import { buildBulkRequestBody } from '../helpers';
4142

4243
export const BulkAssignOrganizationModal = props => (
4344
<BulkAssignTaxonomyModal modalType={MODAL_TYPES.ORGANIZATION} {...props} />
@@ -52,6 +53,8 @@ const BulkAssignTaxonomyModal = ({
5253
selectAllHostsMode,
5354
selectedCount,
5455
fetchBulkParams,
56+
organizationId,
57+
locationId,
5558
modalType,
5659
}) => {
5760
const org = modalType === MODAL_TYPES.ORGANIZATION;
@@ -136,13 +139,13 @@ const BulkAssignTaxonomyModal = ({
136139
};
137140

138141
const handleSave = () => {
139-
const requestBody = {
140-
included: {
141-
search: fetchBulkParams(),
142-
},
142+
const requestBody = buildBulkRequestBody({
143+
fetchBulkParams,
144+
organizationId,
145+
locationId,
143146
id: taxId,
144147
mismatch_setting: fixRadioChecked,
145-
};
148+
});
146149

147150
org
148151
? dispatch(
@@ -243,10 +246,14 @@ BulkAssignTaxonomyModal.propTypes = {
243246
selectedCount: PropTypes.number.isRequired,
244247
selectAllHostsMode: PropTypes.bool.isRequired,
245248
fetchBulkParams: PropTypes.func.isRequired,
249+
organizationId: PropTypes.number,
250+
locationId: PropTypes.number,
246251
modalType: PropTypes.string.isRequired,
247252
};
248253

249254
BulkAssignTaxonomyModal.defaultProps = {
250255
isOpen: false,
251256
closeModal: () => {},
257+
organizationId: undefined,
258+
locationId: undefined,
252259
};

webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/assignTaxonomy/index.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,43 @@ import {
77
} from './BulkAssignTaxonomyModal';
88

99
export const BulkAssignOrganizationModalScene = ({ isOpen, closeModal }) => {
10-
const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext(
11-
ForemanActionsBarContext
12-
);
10+
const {
11+
selectAllHostsMode,
12+
selectedCount,
13+
fetchBulkParams,
14+
organizationId,
15+
locationId,
16+
} = useContext(ForemanActionsBarContext);
1317
return (
1418
<BulkAssignOrganizationModal
1519
key="bulk-assign-organization-modal"
1620
selectAllHostsMode={selectAllHostsMode}
1721
selectedCount={selectedCount}
1822
fetchBulkParams={fetchBulkParams}
23+
organizationId={organizationId}
24+
locationId={locationId}
1925
isOpen={isOpen}
2026
closeModal={closeModal}
2127
/>
2228
);
2329
};
2430

2531
export const BulkAssignLocationModalScene = ({ isOpen, closeModal }) => {
26-
const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext(
27-
ForemanActionsBarContext
28-
);
32+
const {
33+
selectAllHostsMode,
34+
selectedCount,
35+
fetchBulkParams,
36+
organizationId,
37+
locationId,
38+
} = useContext(ForemanActionsBarContext);
2939
return (
3040
<BulkAssignLocationModal
3141
key="bulk-assign-location-modal"
3242
selectAllHostsMode={selectAllHostsMode}
3343
selectedCount={selectedCount}
3444
fetchBulkParams={fetchBulkParams}
45+
organizationId={organizationId}
46+
locationId={locationId}
3547
isOpen={isOpen}
3648
closeModal={closeModal}
3749
/>

webpack/assets/javascripts/react_app/components/HostsIndex/BulkActions/buildHosts/BulkBuildHostModal.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '@patternfly/react-core';
1313
import { addToast } from '../../../ToastsList/slice';
1414
import { translate as __ } from '../../../../common/I18n';
15-
import { failedHostsToastParams } from '../helpers';
15+
import { buildBulkRequestBody, failedHostsToastParams } from '../helpers';
1616
import { STATUS } from '../../../../constants';
1717
import { selectAPIStatus } from '../../../../redux/API/APISelectors';
1818
import { bulkBuildHosts, HOST_BUILD_KEY } from './actions';
@@ -22,6 +22,8 @@ const BulkBuildHostModal = ({
2222
closeModal,
2323
selectedCount,
2424
fetchBulkParams,
25+
organizationId,
26+
locationId,
2527
}) => {
2628
const dispatch = useDispatch();
2729
const [buildRadioChecked, setBuildRadioChecked] = useState(true);
@@ -44,13 +46,13 @@ const BulkBuildHostModal = ({
4446
);
4547
};
4648
const handleSave = () => {
47-
const requestBody = {
48-
included: {
49-
search: fetchBulkParams(),
50-
},
49+
const requestBody = buildBulkRequestBody({
50+
fetchBulkParams,
51+
organizationId,
52+
locationId,
5153
reboot: rebootChecked,
5254
rebuild_configuration: !buildRadioChecked,
53-
};
55+
});
5456

5557
dispatch(bulkBuildHosts(requestBody, handleModalClose, handleError));
5658
};
@@ -156,11 +158,15 @@ BulkBuildHostModal.propTypes = {
156158
closeModal: PropTypes.func,
157159
selectedCount: PropTypes.number.isRequired,
158160
fetchBulkParams: PropTypes.func.isRequired,
161+
organizationId: PropTypes.number,
162+
locationId: PropTypes.number,
159163
};
160164

161165
BulkBuildHostModal.defaultProps = {
162166
isOpen: false,
163167
closeModal: () => {},
168+
organizationId: undefined,
169+
locationId: undefined,
164170
};
165171

166172
export default BulkBuildHostModal;

0 commit comments

Comments
 (0)