From a6a902f51d7389b97cf5346184acd23241bd46d5 Mon Sep 17 00:00:00 2001 From: Nadja Heitmann Date: Wed, 20 May 2026 07:39:59 +0000 Subject: [PATCH] Add Puppet environment bulk actions Co-Authored-By: OpenAI Codex --- .../extensions/hosts_controller_extensions.rb | 2 +- .../api/v2/hosts_bulk_actions_controller.rb | 76 +++++- .../extensions/bulk_hosts_manager.rb | 18 ++ config/api_routes.rb | 2 + lib/foreman_puppet/register.rb | 1 + .../v2/hosts_bulk_actions_controller_test.rb | 97 +++++++ .../foreman_puppet/bulk_hosts_manager_test.rb | 40 +++ webpack/global_index.js | 19 +- webpack/src/Extends/Hosts/ActionsBar/index.js | 20 ++ .../BulkChangePuppetEnvironmentModal.js | 243 ++++++++++++++++++ .../BulkChangePuppetEnvironmentModal.test.js | 150 +++++++++++ .../__tests__/actions.test.js | 49 ++++ .../__tests__/index.test.js | 63 +++++ .../BulkChangePuppetEnvironment/actions.js | 36 +++ .../BulkChangePuppetEnvironment/index.js | 26 ++ .../BulkRemovePuppetEnvironmentModal.js | 153 +++++++++++ .../BulkRemovePuppetEnvironmentModal.test.js | 76 ++++++ .../__tests__/index.test.js | 63 +++++ .../BulkRemovePuppetEnvironment/index.js | 26 ++ .../__tests__/index.test.js | 10 +- webpack/src/foreman_puppet_host_form.test.js | 4 +- 21 files changed, 1167 insertions(+), 7 deletions(-) create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/BulkChangePuppetEnvironmentModal.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/BulkChangePuppetEnvironmentModal.test.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/actions.test.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/index.test.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/actions.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/index.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/BulkRemovePuppetEnvironmentModal.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/BulkRemovePuppetEnvironmentModal.test.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/index.test.js create mode 100644 webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/index.js diff --git a/app/controllers/concerns/foreman_puppet/extensions/hosts_controller_extensions.rb b/app/controllers/concerns/foreman_puppet/extensions/hosts_controller_extensions.rb index a2b2de94..6cfd7a2d 100644 --- a/app/controllers/concerns/foreman_puppet/extensions/hosts_controller_extensions.rb +++ b/app/controllers/concerns/foreman_puppet/extensions/hosts_controller_extensions.rb @@ -126,7 +126,7 @@ def get_environment_id(env_params) def get_environment_for(host, id) if id == 'inherit' && host.hostgroup.present? - host.hostgroup.environment + host.hostgroup.puppet&.environment else ForemanPuppet::Environment.find_by(id: id) end diff --git a/app/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller.rb b/app/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller.rb index cf7ab289..6f18d1d0 100644 --- a/app/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller.rb +++ b/app/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller.rb @@ -3,7 +3,9 @@ module Api module V2 class HostsBulkActionsController < ::ForemanPuppet::Api::V2::PuppetBaseController include ::Api::V2::BulkHostsExtension - before_action :find_editable_hosts, only: %i[change_puppet_proxy remove_puppet_proxy] + before_action :find_editable_hosts, only: %i[change_environment change_puppet_proxy remove_puppet_proxy] + before_action :find_environment, only: %i[change_environment] + before_action :validate_environment_taxonomies, only: %i[change_environment] before_action :find_smart_proxy, only: %i[change_puppet_proxy] def_param_group :bulk_params do @@ -17,6 +19,27 @@ class HostsBulkActionsController < ::ForemanPuppet::Api::V2::PuppetBaseControlle end end + api :PUT, '/foreman_puppet/api/v2/hosts/bulk/change_environment', N_('Change Puppet environment') + param_group :bulk_params + param :environment_id, String, required: false, desc: N_('ID of the Puppet environment to set for the selected hosts') + def change_environment + error_hosts = ::BulkHostsManager.new(hosts: @hosts).change_puppet_environment(resolved_environment) + + process_bulk_response( + error_hosts, + success_message: format(n_( + 'Updated host: changed environment', + 'Updated hosts: changed environment', + @hosts.count + )), + error_message: format(n_( + 'Failed to change environment for %{count} host', + 'Failed to change environment for %{count} hosts', + error_hosts.count + ), count: error_hosts.count) + ) + end + api :PUT, '/hosts/bulk/change_puppet_proxy', N_('Change Puppet (CA) Proxy') param_group :bulk_params param :proxy_id, :number, required: true, desc: N_('ID of the Puppet proxy to reassign the hosts to') @@ -65,6 +88,14 @@ def find_editable_hosts end def process_bulk_puppet_proxy_response(error_hosts, success_message:, error_message:) + process_bulk_response( + error_hosts, + success_message: success_message, + error_message: error_message + ) + end + + def process_bulk_response(error_hosts, success_message:, error_message:) if error_hosts.empty? process_response(true, { message: success_message }) else @@ -93,6 +124,49 @@ def ca_proxy? Foreman::Cast.to_bool(params[:ca_proxy]) end + def find_environment + return true if environment_value == 'inherit' || environment_value.blank? + + @environment = ForemanPuppet::Environment.find_by(id: environment_value) + return true if @environment.present? + + render json: { + error: { + message: format(_('A Puppet environment with id %{id} could not be found.'), id: environment_value), + }, + }, status: :unprocessable_entity + false + end + + def validate_environment_taxonomies + return true if resolved_environment.blank? || resolved_environment == 'inherit' + + invalid_host_ids = @hosts.reject do |host| + ForemanPuppet::Environment.with_taxonomy_scope(host.organization, host.location) + .exists?(id: resolved_environment.id) + end.map(&:id) + + return true if invalid_host_ids.empty? + + render_error(:bulk_hosts_error, status: :unprocessable_entity, + locals: { + message: _('Selected Puppet environment is not assigned to the proper organization and/or location for all hosts.'), + failed_host_ids: invalid_host_ids, + }) + false + end + + def environment_value + params[:environment_id] + end + + def resolved_environment + return 'inherit' if environment_value == 'inherit' + return nil if environment_value.blank? + + @environment + end + def proxy_type ca_proxy? ? _('Puppet CA proxy') : _('Puppet proxy') end diff --git a/app/services/concerns/foreman_puppet/extensions/bulk_hosts_manager.rb b/app/services/concerns/foreman_puppet/extensions/bulk_hosts_manager.rb index dd9b07f3..adb65dd7 100644 --- a/app/services/concerns/foreman_puppet/extensions/bulk_hosts_manager.rb +++ b/app/services/concerns/foreman_puppet/extensions/bulk_hosts_manager.rb @@ -3,6 +3,24 @@ module Extensions module BulkHostsManager extend ActiveSupport::Concern + def change_puppet_environment(environment) + error_hosts = [] + @hosts.each do |host| + puppet = host.puppet || host.build_puppet + puppet.environment = if environment == 'inherit' + host.hostgroup&.puppet&.environment + else + environment + end + host.save(validate: false) + rescue StandardError => e + message = format(_('Failed to set Puppet environment for %{host}.'), host: host) + Foreman::Logging.exception(message, e) + error_hosts << host.id + end + error_hosts + end + def change_puppet_proxy(proxy, is_ca_proxy) error_hosts = [] @hosts.each do |host| diff --git a/config/api_routes.rb b/config/api_routes.rb index 1d1b1eca..d19779b1 100644 --- a/config/api_routes.rb +++ b/config/api_routes.rb @@ -12,6 +12,8 @@ ForemanPuppet::Engine.routes.draw do namespace :api, defaults: { format: 'json' } do scope '(:apiv)', module: :v2, defaults: { apiv: 'v2' }, apiv: /v1|v2/, constraints: ApiConstraints.new(version: 2, default: true) do + match 'hosts/bulk/change_environment', to: 'hosts_bulk_actions#change_environment', via: [:put] + constraints(id: %r{[^/]+}) do resources :config_groups, except: %i[new edit] diff --git a/lib/foreman_puppet/register.rb b/lib/foreman_puppet/register.rb index c5293c41..7a1b1c8a 100644 --- a/lib/foreman_puppet/register.rb +++ b/lib/foreman_puppet/register.rb @@ -62,6 +62,7 @@ p.actions << 'hosts/update_multiple_environment' p.actions << 'hosts/select_multiple_puppet_proxy' p.actions << 'hosts/update_multiple_puppet_proxy' + p.actions << 'foreman_puppet/api/v2/hosts_bulk_actions/change_environment' p.actions << 'foreman_puppet/api/v2/hosts_bulk_actions/change_puppet_proxy' p.actions << 'foreman_puppet/api/v2/hosts_bulk_actions/remove_puppet_proxy' end diff --git a/test/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller_test.rb b/test/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller_test.rb index 485bbdad..047c7bab 100644 --- a/test/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller_test.rb +++ b/test/controllers/foreman_puppet/api/v2/hosts_bulk_actions_controller_test.rb @@ -16,8 +16,105 @@ class HostsBulkActionsControllerTest < ActionController::TestCase organization: host.organization, location: host.location) end + let(:environment) { FactoryBot.create(:environment, organizations: [host.organization], locations: [host.location]) } + let(:hostgroup_environment) { FactoryBot.create(:environment, organizations: [host.organization], locations: [host.location]) } + let(:hostgroup) do + FactoryBot.create(:hostgroup, :with_puppet_enc, + environment: hostgroup_environment, + organizations: [host.organization], + locations: [host.location]) + end let(:proxy) { FactoryBot.create(:puppet_and_ca_smart_proxy, organizations: [host.organization], locations: [host.location]) } + def put_change_environment(params:, session: nil) + original_routes = @routes + @routes = ForemanPuppet::Engine.routes + put :change_environment, params: params, session: session + ensure + @routes = original_routes + end + + test 'changes puppet environment for selected hosts' do + put_change_environment(params: bulk_params.merge(environment_id: environment.id)) + + assert_response :success + assert_equal environment.id, host2.reload.puppet.environment_id + assert_equal environment.id, host.reload.puppet.environment_id + end + + test 'inherits puppet environment from hostgroups for selected hosts' do + host.update!(hostgroup: hostgroup) + host2.update!(hostgroup: hostgroup) + + put_change_environment(params: bulk_params.merge(environment_id: 'inherit')) + + assert_response :success + assert_equal hostgroup_environment.id, host2.reload.puppet.environment_id + assert_equal hostgroup_environment.id, host.reload.puppet.environment_id + end + + test 'clears puppet environment for selected hosts' do + host.puppet.update!(environment: environment) + host2.puppet.update!(environment: environment) + + put_change_environment( + params: bulk_params.merge(environment_id: nil), + session: set_session_user + ) + + assert_response :success + assert_nil host.reload.puppet.environment + assert_nil host2.reload.puppet.environment + end + + test 'returns error when puppet environment is missing' do + missing_environment_id = 999_999 + + put_change_environment( + params: bulk_params.merge(environment_id: missing_environment_id), + session: set_session_user + ) + + assert_response :unprocessable_entity + response = JSON.parse(@response.body) + assert_equal "A Puppet environment with id #{missing_environment_id} could not be found.", + response.dig('error', 'message') + end + + test 'returns error when changing puppet environment fails for some hosts' do + ::BulkHostsManager.any_instance.expects(:change_puppet_environment) + .with(environment) + .returns([host2.id]) + + put_change_environment( + params: bulk_params.merge(environment_id: environment.id), + session: set_session_user + ) + + assert_response :unprocessable_entity + response = JSON.parse(@response.body) + assert_equal 'Failed to change environment for 1 host', + response.dig('error', 'message') + assert_equal [host2.id], response.dig('error', 'failed_host_ids') + end + + test 'returns error when puppet environment is outside host taxonomies' do + invalid_environment = FactoryBot.create(:environment, + organizations: [host.organization], + locations: [FactoryBot.create(:location)]) + + put_change_environment( + params: bulk_params.merge(environment_id: invalid_environment.id), + session: set_session_user + ) + + assert_response :unprocessable_entity + response = JSON.parse(@response.body) + assert_equal 'Selected Puppet environment is not assigned to the proper organization and/or location for all hosts.', + response.dig('error', 'message') + assert_equal [host.id, host2.id].sort, response.dig('error', 'failed_host_ids').sort + end + test 'changes puppet proxy for selected hosts' do put :change_puppet_proxy, params: bulk_params.merge(proxy_id: proxy.id, ca_proxy: false) diff --git a/test/services/foreman_puppet/bulk_hosts_manager_test.rb b/test/services/foreman_puppet/bulk_hosts_manager_test.rb index 6aee2364..eb18c7d1 100644 --- a/test/services/foreman_puppet/bulk_hosts_manager_test.rb +++ b/test/services/foreman_puppet/bulk_hosts_manager_test.rb @@ -4,6 +4,46 @@ class BulkHostsManagerTest < ActiveSupport::TestCase let(:hosts) { FactoryBot.create_list(:host, 2, :with_puppet_enc) } let(:manager) { ::BulkHostsManager.new(hosts: hosts) } let(:proxy) { FactoryBot.create(:puppet_smart_proxy) } + let(:environment) { FactoryBot.create(:environment, organizations: [hosts.first.organization], locations: [hosts.first.location]) } + + test 'changes puppet environment for hosts' do + manager.change_puppet_environment(environment) + + hosts.each do |host| + assert_equal environment.id, host.reload.puppet.environment_id + end + end + + test 'inherits puppet environment from hostgroup' do + organization = FactoryBot.create(:organization) + location = FactoryBot.create(:location) + inherited_environment = FactoryBot.create(:environment, organizations: [organization], locations: [location]) + hostgroup = FactoryBot.create(:hostgroup, :with_puppet_enc, + environment: inherited_environment, + organizations: [organization], + locations: [location]) + inherited_hosts = FactoryBot.create_list(:host, 2, :with_puppet_enc, + environment: inherited_environment, + hostgroup: hostgroup, + organization: organization, + location: location) + + ::BulkHostsManager.new(hosts: inherited_hosts).change_puppet_environment('inherit') + + inherited_hosts.each do |host| + assert_equal inherited_environment.id, host.reload.puppet.environment_id + end + end + + test 'clears puppet environment when environment is nil' do + hosts.each { |host| host.puppet.update!(environment: environment) } + + manager.change_puppet_environment(nil) + + hosts.each do |host| + assert_nil host.reload.puppet.environment + end + end test 'changes puppet proxy for hosts' do manager.change_puppet_proxy(proxy, false) diff --git a/webpack/global_index.js b/webpack/global_index.js index 4cdee167..3c67774b 100644 --- a/webpack/global_index.js +++ b/webpack/global_index.js @@ -7,12 +7,15 @@ import reducers from './src/reducers'; import { registerFills } from './src/Extends/Fills'; import { registerLegacy } from './legacy'; import HostsIndexActionsBar from './src/Extends/Hosts/ActionsBar'; +import BulkChangePuppetEnvironment from './src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment'; +import BulkRemovePuppetEnvironment from './src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment'; import BulkChangePuppetProxy from './src/Extends/Hosts/BulkActions/BulkChangePuppetProxy'; import BulkChangePuppetCAProxy from './src/Extends/Hosts/BulkActions/BulkChangePuppetCAProxy'; import BulkRemovePuppetProxy from './src/Extends/Hosts/BulkActions/BulkRemovePuppetProxy'; import BulkRemovePuppetCAProxy from './src/Extends/Hosts/BulkActions/BulkRemovePuppetCAProxy'; const GLOBAL_FILL_PRIORITY = 100; +const PUPPET_ENV_COLUMN_WEIGHT = 2700; // register reducers registerReducer('puppet', reducers); @@ -28,7 +31,7 @@ const puppetHostsIndexColumns = [ title: __('Puppet env'), isSorted: true, wrapper: hostDetails => hostDetails.environment_name, - weight: 2700, + weight: PUPPET_ENV_COLUMN_WEIGHT, }, ]; @@ -45,6 +48,20 @@ addGlobalFill( GLOBAL_FILL_PRIORITY ); +addGlobalFill( + '_all-hosts-modals', + 'BulkChangePuppetEnvironment', + , + GLOBAL_FILL_PRIORITY +); + +addGlobalFill( + '_all-hosts-modals', + 'BulkRemovePuppetEnvironment', + , + GLOBAL_FILL_PRIORITY +); + addGlobalFill( '_all-hosts-modals', 'BulkChangePuppetProxy', diff --git a/webpack/src/Extends/Hosts/ActionsBar/index.js b/webpack/src/Extends/Hosts/ActionsBar/index.js index e32cedf0..92cdc28d 100644 --- a/webpack/src/Extends/Hosts/ActionsBar/index.js +++ b/webpack/src/Extends/Hosts/ActionsBar/index.js @@ -24,6 +24,26 @@ const HostActionsBar = () => { setMenuOpen(false)}> + + handleOpenBulkModal('bulk-change-puppet-environment') + } + isDisabled={selectedCount === 0} + > + {__('Change Puppet environment')} + + + handleOpenBulkModal('bulk-remove-puppet-environment') + } + isDisabled={selectedCount === 0} + > + {__('Remove Puppet environment')} + { + const dispatch = useDispatch(); + const [environmentId, setEnvironmentId] = useState(null); + const [environmentSelectOpen, setEnvironmentSelectOpen] = useState(false); + const currentOrganization = useForemanOrganization(); + + useEffect(() => { + dispatch(fetchEnvironments()); + }, [dispatch]); + + const environments = useSelector(state => + selectAPIResponse(state, PUPPET_ENVIRONMENTS_KEY) + ); + const environmentsStatus = useSelector(state => + selectAPIStatus(state, PUPPET_ENVIRONMENTS_KEY) + ); + + const onToggleClick = () => { + setEnvironmentSelectOpen(!environmentSelectOpen); + }; + + const handleEnvironmentSelect = (event, selection) => { + setEnvironmentId(selection); + setEnvironmentSelectOpen(false); + }; + + const getEnvironmentLabel = value => { + if (value === INHERIT_ENVIRONMENT) return __('*Inherit from host group*'); + + const selectedEnvironment = environments?.results?.find( + environment => `${environment.id}` === value + ); + return selectedEnvironment?.name || __('Select an Environment'); + }; + + const toggle = toggleRef => ( + + {environmentId + ? getEnvironmentLabel(environmentId) + : __('Select an Environment')} + + ); + + const handleModalClose = () => { + setEnvironmentId(null); + closeModal(); + }; + + const handleError = response => { + handleModalClose(); + dispatch( + addToast( + bulkActionErrorToastParams( + response, + __('Failed to change Puppet Environment'), + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY + ) + ) + ); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + handleModalClose(); + }; + + const handleConfirm = () => { + const requestBody = { + included: { + search: fetchBulkParams(), + }, + environment_id: environmentId, + organization_id: currentOrganization?.id, + }; + + dispatch( + bulkChangePuppetEnvironment(requestBody, handleSuccess, handleError) + ); + }; + + const modalActions = [ + , + , + ]; + + return ( + + + + {selectAllHostsMode ? ( + {__('All')}, + }} + /> + ) : ( + {selectedCount}, + }} + /> + )} + + + {environmentsStatus === STATUS.RESOLVED && ( + + )} + + ); +}; + +BulkChangePuppetEnvironmentModal.propTypes = { + isOpen: PropTypes.bool, + closeModal: PropTypes.func, + fetchBulkParams: PropTypes.func.isRequired, + selectedCount: PropTypes.number.isRequired, + selectAllHostsMode: PropTypes.bool.isRequired, +}; + +BulkChangePuppetEnvironmentModal.defaultProps = { + isOpen: false, + closeModal: () => {}, +}; + +export default BulkChangePuppetEnvironmentModal; diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/BulkChangePuppetEnvironmentModal.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/BulkChangePuppetEnvironmentModal.test.js new file mode 100644 index 00000000..06610e7b --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/BulkChangePuppetEnvironmentModal.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProvider } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; + +import BulkChangePuppetEnvironmentModal from '../BulkChangePuppetEnvironmentModal'; +import { + fetchEnvironments, + bulkChangePuppetEnvironment, +} from '../actions'; +import { + selectAPIResponse, + selectAPIStatus, +} from 'foremanReact/redux/API/APISelectors'; + +jest.mock('@patternfly/react-core', () => { + const React = require('react'); + const actual = jest.requireActual('@patternfly/react-core'); + + const MenuToggle = React.forwardRef(({ children, onClick }, ref) => ( + + )); + + const SelectOption = ({ children, value, onSelect }) => ( + + ); + + const SelectList = ({ children, onSelect }) => ( +
+ {React.Children.map(children, child => + React.isValidElement(child) ? React.cloneElement(child, { onSelect }) : child + )} +
+ ); + + const Select = ({ children, isOpen, toggle, onSelect }) => ( +
+ {toggle()} + {isOpen && + React.Children.map(children, child => + React.isValidElement(child) + ? React.cloneElement(child, { onSelect }) + : child + )} +
+ ); + + return { + ...actual, + MenuToggle, + Select, + SelectList, + SelectOption, + }; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('foremanReact/Root/Context/ForemanContext', () => ({ + useForemanOrganization: jest.fn(() => ({ id: 23 })), +})); + +jest.mock('foremanReact/redux/API/APISelectors', () => ({ + selectAPIResponse: jest.fn(), + selectAPIStatus: jest.fn(), +})); + +jest.mock('../actions', () => ({ + fetchEnvironments: jest.fn(() => ({ type: 'FETCH_ENVIRONMENTS' })), + bulkChangePuppetEnvironment: jest.fn(() => ({ + type: 'BULK_CHANGE_PUPPET_ENVIRONMENT', + })), + PUPPET_ENVIRONMENTS_KEY: 'PUPPET_ENVIRONMENTS_KEY', + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY: 'BULK_CHANGE_PUPPET_ENVIRONMENT', + INHERIT_ENVIRONMENT: 'inherit', +})); + +describe('BulkChangePuppetEnvironmentModal', () => { + const dispatch = jest.fn(); + const fetchBulkParams = jest.fn(() => 'organization = "Default Organization"'); + const environmentsResponse = { + results: [{ id: 1, name: 'production' }], + }; + + const renderComponent = () => + render( + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + useDispatch.mockReturnValue(dispatch); + selectAPIResponse.mockReturnValue(environmentsResponse); + selectAPIStatus.mockReturnValue('RESOLVED'); + useSelector.mockImplementation(selector => selector({})); + }); + + it('renders the environment options', () => { + renderComponent(); + + expect(fetchEnvironments).toHaveBeenCalled(); + expect(screen.getAllByText('Change Puppet Environment')[0]).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Select an Environment' }) + ).toBeInTheDocument(); + expect( + screen.getByText(/Changing the Puppet environment will affect/) + ).toBeInTheDocument(); + }); + + it('submits organization scope with the bulk change request', () => { + renderComponent(); + + fireEvent.click(screen.getByRole('button', { name: 'Select an Environment' })); + expect(screen.getByText('*Inherit from host group*')).toBeInTheDocument(); + fireEvent.click(screen.getByText('production')); + fireEvent.click( + screen.getByRole('button', { name: 'Change Puppet Environment' }) + ); + + expect(bulkChangePuppetEnvironment).toHaveBeenCalledWith( + { + included: { + search: 'organization = "Default Organization"', + }, + environment_id: '1', + organization_id: 23, + }, + expect.any(Function), + expect.any(Function) + ); + }); +}); diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/actions.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/actions.test.js new file mode 100644 index 00000000..7075de7b --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/actions.test.js @@ -0,0 +1,49 @@ +import { APIActions } from 'foremanReact/redux/API'; +import { + fetchEnvironments, + PUPPET_ENVIRONMENTS_KEY, + bulkChangePuppetEnvironment, + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY, +} from '../actions'; + +jest.mock('foremanReact/redux/API', () => ({ + APIActions: { + get: jest.fn(), + put: jest.fn(), + }, +})); + +describe('BulkChangePuppetEnvironment actions', () => { + const environmentsUrl = '/foreman_puppet/api/v2/environments'; + const bulkChangeUrl = '/foreman_puppet/api/v2/hosts/bulk/change_environment'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches environments', () => { + fetchEnvironments(); + + expect(APIActions.get).toHaveBeenCalledWith({ + key: PUPPET_ENVIRONMENTS_KEY, + url: environmentsUrl, + params: { per_page: 'all' }, + }); + }); + + it('calls bulk change puppet environment endpoint', () => { + const params = { included: { ids: [1] }, environment_id: '1' }; + const handleSuccess = jest.fn(); + const handleError = jest.fn(); + + bulkChangePuppetEnvironment(params, handleSuccess, handleError); + + expect(APIActions.put).toHaveBeenCalledWith({ + key: BULK_CHANGE_PUPPET_ENVIRONMENT_KEY, + url: bulkChangeUrl, + handleSuccess, + handleError, + params, + }); + }); +}); diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/index.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/index.test.js new file mode 100644 index 00000000..d58b0324 --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/__tests__/index.test.js @@ -0,0 +1,63 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; + +import { useBulkModalOpen } from 'foremanReact/common/BulkModalStateHelper'; +import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar'; + +import BulkChangePuppetEnvironmentScene from '../index'; +import BulkChangePuppetEnvironmentModal from '../BulkChangePuppetEnvironmentModal'; + +jest.mock('foremanReact/common/BulkModalStateHelper', () => ({ + useBulkModalOpen: jest.fn(), +})); + +jest.mock('../BulkChangePuppetEnvironmentModal', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +describe('BulkChangePuppetEnvironmentScene', () => { + const fetchBulkParams = jest.fn(); + const contextValue = { + selectAllHostsMode: false, + selectedCount: 2, + selectedResults: [1, 2], + fetchBulkParams, + }; + + beforeEach(() => { + jest.clearAllMocks(); + useBulkModalOpen.mockReturnValue({ + isOpen: true, + close: jest.fn(), + }); + }); + + it('opens with bulk modal state and passes expected props', () => { + let component; + act(() => { + component = renderer.create( + + + + ); + }); + + const componentType = + BulkChangePuppetEnvironmentModal.default || + BulkChangePuppetEnvironmentModal; + const { props } = component.root.findByType(componentType); + + expect(props).toEqual( + expect.objectContaining({ + fetchBulkParams, + selectedCount: 2, + selectAllHostsMode: false, + isOpen: true, + closeModal: expect.any(Function), + }) + ); + + component.unmount(); + }); +}); diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/actions.js b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/actions.js new file mode 100644 index 00000000..301046b2 --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/actions.js @@ -0,0 +1,36 @@ +import { APIActions } from 'foremanReact/redux/API'; +import { foremanUrl } from 'foremanReact/common/helpers'; + +export const PUPPET_ENVIRONMENTS_KEY = 'PUPPET_ENVIRONMENTS_KEY'; +export const BULK_CHANGE_PUPPET_ENVIRONMENT_KEY = + 'BULK_CHANGE_PUPPET_ENVIRONMENT'; + +export const INHERIT_ENVIRONMENT = 'inherit'; + +export const fetchEnvironments = () => { + const url = foremanUrl('/foreman_puppet/api/v2/environments'); + return APIActions.get({ + key: PUPPET_ENVIRONMENTS_KEY, + url, + params: { per_page: 'all' }, + }); +}; + +export const bulkChangePuppetEnvironment = ( + params, + handleSuccess, + handleError +) => { + const url = foremanUrl( + '/foreman_puppet/api/v2/hosts/bulk/change_environment' + ); + return APIActions.put({ + key: BULK_CHANGE_PUPPET_ENVIRONMENT_KEY, + url, + handleSuccess, + handleError, + params, + }); +}; + +export default fetchEnvironments; diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/index.js b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/index.js new file mode 100644 index 00000000..4fdd9cad --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkChangePuppetEnvironment/index.js @@ -0,0 +1,26 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar'; +import { useBulkModalOpen } from 'foremanReact/common/BulkModalStateHelper'; +import BulkChangePuppetEnvironmentModal from './BulkChangePuppetEnvironmentModal'; + +const BulkChangePuppetEnvironmentScene = () => { + const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext( + ForemanActionsBarContext + ); + const { isOpen, close: closeModal } = useBulkModalOpen( + 'bulk-change-puppet-environment' + ); + + return ( + + ); +}; + +export { BulkChangePuppetEnvironmentModal }; +export default BulkChangePuppetEnvironmentScene; diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/BulkRemovePuppetEnvironmentModal.js b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/BulkRemovePuppetEnvironmentModal.js new file mode 100644 index 00000000..c163d5ef --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/BulkRemovePuppetEnvironmentModal.js @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { Modal, Button, TextContent, Text } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { addToast } from 'foremanReact/components/ToastsList/slice'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { useForemanOrganization } from 'foremanReact/Root/Context/ForemanContext'; +import { APIActions } from 'foremanReact/redux/API'; +import { + HOSTS_API_PATH, + API_REQUEST_KEY, +} from 'foremanReact/routes/Hosts/constants'; +import { bulkActionErrorToastParams } from '../toastHelpers'; +import { + bulkChangePuppetEnvironment, + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY, +} from '../BulkChangePuppetEnvironment/actions'; + +const BulkRemovePuppetEnvironmentModal = ({ + isOpen, + closeModal, + selectAllHostsMode, + selectedCount, + fetchBulkParams, +}) => { + const dispatch = useDispatch(); + const currentOrganization = useForemanOrganization(); + + const handleModalClose = () => { + closeModal(); + }; + + const handleError = response => { + handleModalClose(); + dispatch( + addToast( + bulkActionErrorToastParams( + response, + __('Failed to remove Puppet Environment'), + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY + ) + ) + ); + }; + + const handleSuccess = response => { + dispatch( + addToast({ + type: 'success', + message: response.data.message, + }) + ); + dispatch( + APIActions.get({ + key: API_REQUEST_KEY, + url: foremanUrl(HOSTS_API_PATH), + }) + ); + handleModalClose(); + }; + + const handleConfirm = () => { + const requestBody = { + included: { + search: fetchBulkParams(), + }, + environment_id: null, + organization_id: currentOrganization?.id, + }; + + dispatch( + bulkChangePuppetEnvironment(requestBody, handleSuccess, handleError) + ); + }; + + const modalActions = [ + , + , + ]; + + return ( + + + + {selectAllHostsMode ? ( + {__('All')}, + }} + /> + ) : ( + {selectedCount}, + }} + /> + )} + + + + ); +}; + +BulkRemovePuppetEnvironmentModal.propTypes = { + isOpen: PropTypes.bool, + closeModal: PropTypes.func, + fetchBulkParams: PropTypes.func.isRequired, + selectedCount: PropTypes.number.isRequired, + selectAllHostsMode: PropTypes.bool.isRequired, +}; + +BulkRemovePuppetEnvironmentModal.defaultProps = { + isOpen: false, + closeModal: () => {}, +}; + +export default BulkRemovePuppetEnvironmentModal; diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/BulkRemovePuppetEnvironmentModal.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/BulkRemovePuppetEnvironmentModal.test.js new file mode 100644 index 00000000..306c690b --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/BulkRemovePuppetEnvironmentModal.test.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IntlProvider } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import BulkRemovePuppetEnvironmentModal from '../BulkRemovePuppetEnvironmentModal'; +import { bulkChangePuppetEnvironment } from '../../BulkChangePuppetEnvironment/actions'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.mock('foremanReact/Root/Context/ForemanContext', () => ({ + useForemanOrganization: jest.fn(() => ({ id: 23 })), +})); + +jest.mock('../../BulkChangePuppetEnvironment/actions', () => ({ + bulkChangePuppetEnvironment: jest.fn(() => ({ + type: 'BULK_CHANGE_PUPPET_ENVIRONMENT', + })), + BULK_CHANGE_PUPPET_ENVIRONMENT_KEY: 'BULK_CHANGE_PUPPET_ENVIRONMENT', +})); + +describe('BulkRemovePuppetEnvironmentModal', () => { + const dispatch = jest.fn(); + + const renderComponent = (fetchBulkParams = jest.fn()) => + render( + + + + ); + + beforeEach(() => { + jest.clearAllMocks(); + useDispatch.mockReturnValue(dispatch); + }); + + it('renders the removal warning', () => { + renderComponent(); + + expect(screen.getAllByText('Remove Puppet Environment')[0]).toBeInTheDocument(); + expect( + screen.getByText(/Removing the Puppet environment will affect/) + ).toBeInTheDocument(); + }); + + it('submits organization scope with the bulk remove request', () => { + const fetchBulkParams = jest.fn(() => 'organization = "Default Organization"'); + renderComponent(fetchBulkParams); + + fireEvent.click( + screen.getByRole('button', { name: 'Remove Puppet Environment' }) + ); + + expect(bulkChangePuppetEnvironment).toHaveBeenCalledWith( + { + included: { + search: 'organization = "Default Organization"', + }, + environment_id: null, + organization_id: 23, + }, + expect.any(Function), + expect.any(Function) + ); + }); +}); diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/index.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/index.test.js new file mode 100644 index 00000000..6cf593b0 --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/__tests__/index.test.js @@ -0,0 +1,63 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; + +import { useBulkModalOpen } from 'foremanReact/common/BulkModalStateHelper'; +import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar'; + +import BulkRemovePuppetEnvironmentScene from '../index'; +import BulkRemovePuppetEnvironmentModal from '../BulkRemovePuppetEnvironmentModal'; + +jest.mock('foremanReact/common/BulkModalStateHelper', () => ({ + useBulkModalOpen: jest.fn(), +})); + +jest.mock('../BulkRemovePuppetEnvironmentModal', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +describe('BulkRemovePuppetEnvironmentScene', () => { + const fetchBulkParams = jest.fn(); + const contextValue = { + selectAllHostsMode: false, + selectedCount: 2, + selectedResults: [1, 2], + fetchBulkParams, + }; + + beforeEach(() => { + jest.clearAllMocks(); + useBulkModalOpen.mockReturnValue({ + isOpen: true, + close: jest.fn(), + }); + }); + + it('opens with bulk modal state and passes expected props', () => { + let component; + act(() => { + component = renderer.create( + + + + ); + }); + + const componentType = + BulkRemovePuppetEnvironmentModal.default || + BulkRemovePuppetEnvironmentModal; + const { props } = component.root.findByType(componentType); + + expect(props).toEqual( + expect.objectContaining({ + fetchBulkParams, + selectedCount: 2, + selectAllHostsMode: false, + isOpen: true, + closeModal: expect.any(Function), + }) + ); + + component.unmount(); + }); +}); diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/index.js b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/index.js new file mode 100644 index 00000000..d40d4ced --- /dev/null +++ b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetEnvironment/index.js @@ -0,0 +1,26 @@ +import React, { useContext } from 'react'; +import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/ActionsBar'; +import { useBulkModalOpen } from 'foremanReact/common/BulkModalStateHelper'; +import BulkRemovePuppetEnvironmentModal from './BulkRemovePuppetEnvironmentModal'; + +const BulkRemovePuppetEnvironmentScene = () => { + const { selectAllHostsMode, selectedCount, fetchBulkParams } = useContext( + ForemanActionsBarContext + ); + const { isOpen, close: closeModal } = useBulkModalOpen( + 'bulk-remove-puppet-environment' + ); + + return ( + + ); +}; + +export { BulkRemovePuppetEnvironmentModal }; +export default BulkRemovePuppetEnvironmentScene; diff --git a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetProxy/__tests__/index.test.js b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetProxy/__tests__/index.test.js index 9f0b0429..a5b057fa 100644 --- a/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetProxy/__tests__/index.test.js +++ b/webpack/src/Extends/Hosts/BulkActions/BulkRemovePuppetProxy/__tests__/index.test.js @@ -7,9 +7,13 @@ import { ForemanActionsBarContext } from 'foremanReact/components/HostDetails/Ac import BulkRemovePuppetProxyScene from '../index'; import BulkRemoveProxyCommon from '../../BulkRemoveProxyCommon'; -jest.mock('foremanReact/components/HostDetails/ActionsBar', () => ({ - ForemanActionsBarContext: jest.requireActual('react').createContext(), -})); +jest.mock( + 'foremanReact/components/HostDetails/ActionsBar', + () => ({ + ForemanActionsBarContext: jest.requireActual('react').createContext(), + }), + { virtual: true } +); jest.mock('../../BulkRemoveProxyCommon', () => ({ __esModule: true, diff --git a/webpack/src/foreman_puppet_host_form.test.js b/webpack/src/foreman_puppet_host_form.test.js index 240e0bf3..b0f8c35c 100644 --- a/webpack/src/foreman_puppet_host_form.test.js +++ b/webpack/src/foreman_puppet_host_form.test.js @@ -4,6 +4,8 @@ import $ from 'jquery'; import { checkForUnavailablePuppetclasses } from './foreman_puppet_host_form'; +const WARNING_RENDER_TIMEOUT = 100; + jest.unmock('jquery'); jest.unmock('./foreman_puppet_host_form'); @@ -59,6 +61,6 @@ describe('checkForUnavailablePuppetclasses', () => { checkForUnavailablePuppetclasses(); setTimeout(() => { expect($('a .pficon')).toHaveLength(1); - }, 100); + }, WARNING_RENDER_TIMEOUT); }); });