foreman_remote_execution 16.6.3 → 16.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fd2360fc3d4b48170494112c77f2d07079e94e52c8c6c9b5fcbe2402a690a49
4
- data.tar.gz: ef36ba8ac16d062f24ee3fd78954d26c86c51001c3b6c8e85dcc1c9d01667159
3
+ metadata.gz: 803af8d6f1617172c150d68158d14d6fa9df2e5cc431226c1c0522f679420c7b
4
+ data.tar.gz: 43b21d05688e374f58999c890d0ed3fedcf8e98d978be053bc52f83701e89292
5
5
  SHA512:
6
- metadata.gz: 96dcba56a99ca8340051cd788eb0115dde2dbcd8ff7cfce5dc82ca6f3679300cebe0c98e74d1fb9c61a09d0e2293d48140996b717114eda87c414968272dcd07
7
- data.tar.gz: 5e33d478ff9bbaaab3edfac6570e378407a9bf501698ad83b31aecfd0b9261293c1d8229d5cd0f014587478a238341af0fb83defdeb5f58b193d21cd8669335a
6
+ metadata.gz: 9fed722946268e04461455cffe63c27add5a23a5a0e2bbdd698a2d7bd7c94017deb0342650ecaadf92426d64db8e40682f8871ab4a43a7e60e24c1096c230bb0
7
+ data.tar.gz: c11bfa8d9b1e72d2bb35d11829509923b6fb7babffd98a7782d5c8718adc9628def8739d388be1f5de07c239ba8b852c9de907c8e1b77383c573f460813ccff7
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.6.3'.freeze
2
+ VERSION = '16.6.5'.freeze
3
3
  end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('../test_plugin_helper', __dir__)
4
+ require 'integration_test_helper'
5
+ require 'securerandom'
6
+
7
+ class JobWizardCategoryJsTest < IntegrationTestWithJavascript
8
+ WIZARD_INPUT_NAME_BASE = 'wizard_integration_input'
9
+ WIZARD_FEATURE_INPUT_NAME_BASE = 'wizard_integration_feature_input'
10
+
11
+ setup do
12
+ unique_suffix = SecureRandom.hex(6)
13
+ @wizard_input_name = "#{WIZARD_INPUT_NAME_BASE}_#{unique_suffix}"
14
+ @wizard_feature_input_name = "#{WIZARD_FEATURE_INPUT_NAME_BASE}_#{unique_suffix}"
15
+ default_template_name = "Integration Default REX Form Template #{unique_suffix}"
16
+ feature_template_name = "Integration Feature REX Template #{unique_suffix}"
17
+
18
+ # Prevent real Dynflow proxy calls in CI while planning jobs.
19
+ ProxyAPI::ForemanDynflow::DynflowProxy.any_instance.stubs(:tasks_count).returns(0)
20
+
21
+ @original_rex_form_template = Setting[:remote_execution_form_job_template]
22
+ as_admin do
23
+ @default_job_template = FactoryBot.create(
24
+ :job_template,
25
+ :name => default_template_name,
26
+ :job_category => 'Default Form Category'
27
+ )
28
+ @feature_job_template = FactoryBot.create(
29
+ :job_template,
30
+ :name => feature_template_name,
31
+ :job_category => 'Feature Linked Category'
32
+ )
33
+ add_plain_template_input!(@default_job_template, :name => @wizard_input_name)
34
+ add_plain_template_input!(@feature_job_template, :name => @wizard_feature_input_name)
35
+ Setting[:remote_execution_form_job_template] = @default_job_template.name
36
+ @remote_execution_feature = FactoryBot.create(
37
+ :remote_execution_feature,
38
+ :label => "integration_rex_category_feature_#{unique_suffix}",
39
+ :name => "Integration REX Category Feature #{unique_suffix}",
40
+ :job_template => @feature_job_template
41
+ )
42
+ @rex_host = FactoryBot.create(:host, :with_execution)
43
+ end
44
+ end
45
+
46
+ teardown do
47
+ Setting[:remote_execution_form_job_template] = @original_rex_form_template
48
+ end
49
+
50
+ test 'job wizard shows feature template job category when feature query param is present' do
51
+ visit new_job_invocation_path(:feature => @remote_execution_feature.label)
52
+ assert_text 'Category and template', :wait => 30
53
+ wait_for_ajax
54
+ assert_selector(:ouia_component_id, 'job_category', :text => 'Feature Linked Category', :wait => 30)
55
+ end
56
+
57
+ test 'job wizard shows default form category when no feature query param' do
58
+ visit new_job_invocation_path
59
+ assert_text 'Category and template', :wait => 30
60
+ wait_for_ajax
61
+ assert_selector(:ouia_component_id, 'job_category', :text => 'Default Form Category', :wait => 30)
62
+ end
63
+
64
+ test 'job wizard prefills template input from inputs query param' do
65
+ visit new_job_invocation_path(
66
+ :inputs => { @wizard_input_name => 'from_query_string' },
67
+ :search => ''
68
+ )
69
+ go_to_target_hosts_and_inputs_step!
70
+ assert_selector "textarea##{@wizard_input_name}", :wait => 30
71
+ assert_equal 'from_query_string', find("textarea##{@wizard_input_name}").value
72
+ end
73
+
74
+ test 'job wizard does not prefill template input when inputs query param is omitted' do
75
+ visit new_job_invocation_path(:search => '')
76
+ go_to_target_hosts_and_inputs_step!
77
+ assert_selector "textarea##{@wizard_input_name}", :wait => 30
78
+ assert_equal '', find("textarea##{@wizard_input_name}").value
79
+ end
80
+
81
+ test 'job wizard prefills template input when feature and inputs query params are present' do
82
+ visit new_job_invocation_path(
83
+ :feature => @remote_execution_feature.label,
84
+ :inputs => { @wizard_feature_input_name => 'feature_and_inputs' },
85
+ :search => ''
86
+ )
87
+ go_to_target_hosts_and_inputs_step!
88
+ assert_selector "textarea##{@wizard_feature_input_name}", :wait => 30
89
+ assert_equal 'feature_and_inputs', find("textarea##{@wizard_feature_input_name}").value
90
+ end
91
+
92
+ test 'job wizard run on selected hosts is enabled on review and posts to job invocations API' do
93
+ posted_to_job_invocations_create = false
94
+ subscriber = ActiveSupport::Notifications.subscribe('start_processing.action_controller') do |_n, _s, _f, _i, payload|
95
+ next unless payload[:controller] == 'Api::V2::JobInvocationsController' && payload[:action].to_s == 'create'
96
+ root = payload[:params]
97
+ root = root.to_unsafe_h if root.respond_to?(:to_unsafe_h)
98
+ job_inv = (root[:job_invocation] || root['job_invocation'])
99
+ next unless job_inv
100
+ job_inv = job_inv.to_unsafe_h if job_inv.respond_to?(:to_unsafe_h)
101
+ next unless job_inv['job_template_id'].to_i == @default_job_template.id
102
+ posted_to_job_invocations_create = true
103
+ end
104
+
105
+ begin
106
+ visit new_job_invocation_path(:search => "id = #{@rex_host.id}")
107
+ navigate_job_wizard_to_review_step!
108
+
109
+ assert_selector(:ouia_component_id, 'run-on-selected-hosts-footer', :wait => 30)
110
+ run_on_hosts = find(:ouia_component_id, 'run-on-selected-hosts-footer')
111
+ assert_not_equal 'true', run_on_hosts[:'aria-disabled'], 'expected run-on-selected-hosts button to be enabled'
112
+ assert_text 'Run on selected hosts'
113
+ find(:ouia_component_id, 'run-on-selected-hosts-footer').click
114
+ wait_for_ajax
115
+ assert posted_to_job_invocations_create, 'expected Api::V2::JobInvocationsController#create to run'
116
+ assert_match(%r{\A/job_invocations/\d+\z}, page.current_path)
117
+ ensure
118
+ ActiveSupport::Notifications.unsubscribe(subscriber)
119
+ end
120
+ end
121
+
122
+ test 'job wizard create API request sends job_template_id and inputs for default template' do
123
+ input_value = 'api_payload_default_template'
124
+ job_inv_params = capture_api_job_invocation_params_during(expected_template_id: @default_job_template.id) do
125
+ visit new_job_invocation_path(
126
+ :search => "id = #{@rex_host.id}",
127
+ :inputs => { @wizard_input_name => input_value }
128
+ )
129
+ navigate_job_wizard_to_review_step!
130
+ find(:ouia_component_id, 'run-on-selected-hosts-footer').click
131
+ wait_for_ajax
132
+ end
133
+
134
+ assert job_inv_params, 'expected job_invocation key in POST /api/job_invocations'
135
+ assert job_inv_params[:job_template_id].present?, 'expected job_template_id to be present'
136
+ assert_equal @default_job_template.id, job_inv_params[:job_template_id].to_i
137
+ assert job_inv_params[:feature].blank?
138
+ assert_equal input_value, hash_from_params_object(job_inv_params[:inputs])[@wizard_input_name]
139
+ job_invocation_id = page.current_path.split('/').last.to_i
140
+ assert_equal @default_job_template.job_category, JobInvocation.find(job_invocation_id).job_category, 'expected job category to be the default template category'
141
+ end
142
+
143
+ test 'job wizard create API request sends feature label and inputs without job_template_id' do
144
+ input_value = 'api_payload_feature_template'
145
+ job_inv_params = capture_api_job_invocation_params_during(expected_feature: @remote_execution_feature.label) do
146
+ visit new_job_invocation_path(
147
+ :feature => @remote_execution_feature.label,
148
+ :search => "id = #{@rex_host.id}",
149
+ :inputs => { @wizard_feature_input_name => input_value }
150
+ )
151
+ navigate_job_wizard_to_review_step!
152
+ find(:ouia_component_id, 'run-on-selected-hosts-footer').click
153
+ wait_for_ajax
154
+ end
155
+
156
+ assert job_inv_params, 'expected job_invocation key in POST /api/job_invocations'
157
+ assert_not job_inv_params[:job_template_id].present?
158
+ assert_equal @remote_execution_feature.label, job_inv_params[:feature]
159
+ assert_equal input_value, hash_from_params_object(job_inv_params[:inputs])[@wizard_feature_input_name]
160
+ job_invocation_id = page.current_path.split('/').last.to_i
161
+ assert_equal @feature_job_template.job_category, JobInvocation.find(job_invocation_id).job_category, 'expected job category to be the feature template category'
162
+ end
163
+
164
+ private
165
+
166
+ def capture_api_job_invocation_params_during(expected_template_id: nil, expected_feature: nil)
167
+ captured = nil
168
+ subscriber = ActiveSupport::Notifications.subscribe('start_processing.action_controller') do |_n, _s, _f, _i, payload|
169
+ next unless payload[:controller] == 'Api::V2::JobInvocationsController' && payload[:action].to_s == 'create'
170
+ root = payload[:params]
171
+ root = root.to_unsafe_h if root.respond_to?(:to_unsafe_h)
172
+ job_inv_params = root[:job_invocation] || root['job_invocation']
173
+ next unless job_inv_params
174
+ job_inv_params = job_inv_params.to_unsafe_h if job_inv_params.respond_to?(:to_unsafe_h)
175
+ job_inv_params = job_inv_params.with_indifferent_access
176
+ next if expected_template_id && job_inv_params[:job_template_id].to_i != expected_template_id
177
+ next if expected_feature && job_inv_params[:feature] != expected_feature
178
+ captured = job_inv_params
179
+ end
180
+ begin
181
+ yield
182
+ ensure
183
+ ActiveSupport::Notifications.unsubscribe(subscriber)
184
+ end
185
+ captured
186
+ end
187
+
188
+ def hash_from_params_object(obj)
189
+ return {}.with_indifferent_access if obj.blank?
190
+ h = obj.respond_to?(:to_unsafe_h) ? obj.to_unsafe_h : obj.to_h
191
+ h.with_indifferent_access
192
+ end
193
+
194
+ def add_plain_template_input!(job_template, name:)
195
+ job_template.template_inputs << FactoryBot.build(
196
+ :template_input,
197
+ :name => name,
198
+ :input_type => 'user',
199
+ :value_type => 'plain',
200
+ :required => false
201
+ )
202
+ job_template.save!
203
+ end
204
+
205
+ def job_wizard_click_next!
206
+ wait_for_ajax
207
+ button = find(:ouia_component_id, 'next-footer')
208
+ page.execute_script('arguments[0].scrollIntoView({block: "center"})', button.native)
209
+ button.click
210
+ wait_for_ajax
211
+ end
212
+
213
+ def go_to_target_hosts_and_inputs_step!
214
+ assert_text 'Category and template', :wait => 30
215
+ wait_for_ajax
216
+ job_wizard_click_next!
217
+ assert_text 'Target hosts and inputs', :wait => 30
218
+ wait_for_ajax
219
+ end
220
+
221
+ def navigate_job_wizard_to_review_step!
222
+ assert_text 'Category and template', :wait => 30
223
+ wait_for_ajax
224
+ job_wizard_click_next!
225
+ assert_text 'Target hosts and inputs', :wait => 30
226
+ job_wizard_click_next!
227
+ assert_text 'Advanced fields', :wait => 30
228
+ job_wizard_click_next!
229
+ assert_text 'Immediate execution', :wait => 30
230
+ job_wizard_click_next!
231
+ assert_text 'Review details', :wait => 30
232
+ wait_for_ajax
233
+ end
234
+ end
@@ -24,6 +24,13 @@ export const getJobInvocation = url => dispatch => {
24
24
  handleError: () => {
25
25
  dispatch(stopInterval(JOB_INVOCATION_KEY));
26
26
  },
27
+ errorToast: ({ response }) =>
28
+ // eslint-disable-next-line camelcase
29
+ response?.data?.error?.full_messages?.[0] ||
30
+ // eslint-disable-next-line camelcase
31
+ response?.data?.error?.full_messages ||
32
+ response?.data?.error?.message ||
33
+ 'Error',
27
34
  }),
28
35
  1000
29
36
  );
@@ -61,6 +61,11 @@ section.job-additional-info {
61
61
  }
62
62
 
63
63
  .job-details-table-section {
64
+ &.table-section {
65
+ padding-left: 0;
66
+ padding-right: 0;
67
+ }
68
+
64
69
  section:nth-child(1) {
65
70
  padding: 0;
66
71
  }
@@ -62,10 +62,12 @@ export const JobWizard = ({ rerunData }) => {
62
62
  hostGroups: [],
63
63
  });
64
64
  const [hostsSearchQuery, setHostsSearchQuery] = useState('');
65
+ const [selectedBookmark, setSelectedBookmark] = useState(null);
65
66
  const [fills, setFills] = useState(
66
67
  rerunData
67
68
  ? {
68
69
  search: rerunData?.targeting?.search_query,
70
+ bookmark_id: rerunData?.targeting?.bookmark_id,
69
71
  ...rerunData.inputs,
70
72
  ...routerSearch,
71
73
  }
@@ -251,6 +253,7 @@ export const JobWizard = ({ rerunData }) => {
251
253
  setFills,
252
254
  setSelectedTargets,
253
255
  setHostsSearchQuery,
256
+ setSelectedBookmark,
254
257
  setJobTemplateID,
255
258
  setTemplateValues,
256
259
  setAdvancedValues,
@@ -298,6 +301,8 @@ export const JobWizard = ({ rerunData }) => {
298
301
  setSelected={setSelectedTargets}
299
302
  hostsSearchQuery={hostsSearchQuery}
300
303
  setHostsSearchQuery={setHostsSearchQuery}
304
+ selectedBookmark={selectedBookmark}
305
+ setSelectedBookmark={setSelectedBookmark}
301
306
  />
302
307
  ),
303
308
  canJumpTo: isTemplate,
@@ -474,6 +479,7 @@ export const JobWizard = ({ rerunData }) => {
474
479
  dispatch,
475
480
  selectedTargets,
476
481
  hostsSearchQuery,
482
+ selectedBookmark,
477
483
  location,
478
484
  organization,
479
485
  feature,
@@ -507,6 +513,7 @@ JobWizard.propTypes = {
507
513
  job_category: PropTypes.string,
508
514
  targeting: PropTypes.shape({
509
515
  search_query: PropTypes.string,
516
+ bookmark_id: PropTypes.number,
510
517
  targeting_type: PropTypes.string,
511
518
  randomized_ordering: PropTypes.bool,
512
519
  }),
@@ -1,5 +1,6 @@
1
1
  import { translate as __ } from 'foremanReact/common/I18n';
2
2
  import { foremanUrl } from 'foremanReact/common/helpers';
3
+ import { getControllerSearchProps } from 'foremanReact/constants';
3
4
 
4
5
  export const JOB_TEMPLATES = 'JOB_TEMPLATES';
5
6
  export const JOB_CATEGORIES = 'JOB_CATEGORIES';
@@ -61,6 +62,10 @@ export const hostMethods = {
61
62
 
62
63
  export const hostQuerySearchID = 'mainHostQuery';
63
64
  export const hostsController = 'hosts';
65
+ export const hostsSearchProps = getControllerSearchProps(
66
+ hostsController,
67
+ hostQuerySearchID
68
+ );
64
69
 
65
70
  export const dataName = {
66
71
  [HOSTS]: 'hosts',
@@ -9,6 +9,8 @@ import {
9
9
  } from 'foremanReact/redux/API/APISelectors';
10
10
  import { STATUS } from 'foremanReact/constants';
11
11
  import { selectRouterLocation } from 'foremanReact/routes/RouterSelector';
12
+ import { BOOKMARKS } from 'foremanReact/components/PF4/Bookmarks/BookmarksConstants';
13
+ import { selectBookmarksResults } from 'foremanReact/components/PF4/Bookmarks/BookmarksSelectors';
12
14
 
13
15
  import {
14
16
  JOB_TEMPLATES,
@@ -134,3 +136,9 @@ export const selectRouterSearch = state => {
134
136
  const { search } = selectRouterLocation(state) || {};
135
137
  return URI.parseQuery(search);
136
138
  };
139
+
140
+ const HOSTS_CONTROLLER = 'hosts';
141
+ const BOOKMARKS_HOSTS_KEY = `${BOOKMARKS}_${HOSTS_CONTROLLER.toUpperCase()}`;
142
+
143
+ export const selectHostBookmarks = state =>
144
+ selectBookmarksResults(state, BOOKMARKS_HOSTS_KEY, HOSTS_CONTROLLER);
@@ -3,21 +3,19 @@ import { Provider } from 'react-redux';
3
3
  import { render, fireEvent, screen, act } from '@testing-library/react';
4
4
  import { MockedProvider } from '@apollo/client/testing';
5
5
 
6
- import * as APIHooks from 'foremanReact/common/hooks/API/APIHooks';
7
6
  import * as api from 'foremanReact/redux/API';
8
7
  import JobWizardPageRerun from '../JobWizardPageRerun';
9
8
  import * as selectors from '../JobWizardSelectors';
10
- import { testSetup, mockApi, gqlMock, jobInvocation } from './fixtures';
9
+ import {
10
+ testSetup,
11
+ mockApi,
12
+ gqlMock,
13
+ jobInvocation,
14
+ bookmarksList,
15
+ } from './fixtures';
11
16
 
12
17
  const store = testSetup(selectors, api);
13
18
  mockApi(api);
14
- jest.spyOn(APIHooks, 'useAPI');
15
- APIHooks.useAPI.mockImplementation((action, url) => {
16
- if (url === '/ui_job_wizard/job_invocation?id=57') {
17
- return { response: jobInvocation, status: 'RESOLVED' };
18
- }
19
- return {};
20
- });
21
19
 
22
20
  describe('Job wizard fill', () => {
23
21
  it('fill defaults into fields', async () => {
@@ -76,4 +74,50 @@ describe('Job wizard fill', () => {
76
74
  }).value
77
75
  ).toBe('6');
78
76
  });
77
+
78
+ it('fills bookmark on rerun when job used a bookmark', async () => {
79
+ const bookmark = bookmarksList[0];
80
+ const jobWithBookmark = {
81
+ ...jobInvocation,
82
+ job: {
83
+ ...jobInvocation.job,
84
+ targeting: {
85
+ ...jobInvocation.job.targeting,
86
+ bookmark_id: bookmark.id,
87
+ search_query: null,
88
+ },
89
+ },
90
+ };
91
+
92
+ selectors.selectRerunJobInvocationResponse.mockImplementation(
93
+ () => jobWithBookmark
94
+ );
95
+
96
+ render(
97
+ <MockedProvider mocks={gqlMock} addTypename={false}>
98
+ <Provider store={store}>
99
+ <JobWizardPageRerun
100
+ match={{
101
+ params: { id: '99' },
102
+ }}
103
+ />
104
+ </Provider>
105
+ </MockedProvider>
106
+ );
107
+
108
+ await act(async () => {
109
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
110
+ });
111
+
112
+ const hostMethodSelect = screen.getByRole('button', {
113
+ name: 'host method',
114
+ });
115
+ expect(hostMethodSelect.textContent).toContain('Search query');
116
+
117
+ expect(screen.queryAllByText(bookmark.query)).toHaveLength(1);
118
+
119
+ selectors.selectRerunJobInvocationResponse.mockImplementation(
120
+ () => jobInvocation
121
+ );
122
+ });
79
123
  });
@@ -116,6 +116,22 @@ export const jobTemplateResponse = {
116
116
  ],
117
117
  };
118
118
 
119
+ export const bookmarksList = [
120
+ { id: 19, name: 'my hosts', query: 'name ~ myhost', controller: 'hosts' },
121
+ {
122
+ id: 23,
123
+ name: 'active hosts',
124
+ query: 'last_report > "1 hour ago"',
125
+ controller: 'hosts',
126
+ },
127
+ {
128
+ id: 31,
129
+ name: 'dashboard default',
130
+ query: 'os = centos',
131
+ controller: 'dashboard',
132
+ },
133
+ ];
134
+
119
135
  export const jobCategories = ['Services', 'Ansible Commands', 'Puppet'];
120
136
 
121
137
  export const testSetup = (selectors, api) => {
@@ -176,6 +192,14 @@ export const testSetup = (selectors, api) => {
176
192
  subtotal: 3,
177
193
  },
178
194
  },
195
+ bookmarksPF4: {
196
+ hosts: {
197
+ results: bookmarksList,
198
+ },
199
+ },
200
+ API: {
201
+ BOOKMARKS_HOSTS: { status: 'RESOLVED', results: [] },
202
+ },
179
203
  });
180
204
  return store;
181
205
  };
@@ -1,7 +1,14 @@
1
- import { useEffect } from 'react';
2
- import { useDispatch } from 'react-redux';
1
+ import { useEffect, useState } from 'react';
2
+ import { useDispatch, useSelector } from 'react-redux';
3
3
  import { get } from 'foremanReact/redux/API';
4
- import { HOST_IDS, REX_FEATURE } from './JobWizardConstants';
4
+ import { getBookmarks } from 'foremanReact/components/PF4/Bookmarks/BookmarksActions';
5
+ import {
6
+ HOST_IDS,
7
+ REX_FEATURE,
8
+ hostsController,
9
+ hostsSearchProps,
10
+ } from './JobWizardConstants';
11
+ import { selectHostBookmarks } from './JobWizardSelectors';
5
12
  import './JobWizard.scss';
6
13
 
7
14
  export const useAutoFill = ({
@@ -9,11 +16,28 @@ export const useAutoFill = ({
9
16
  setFills,
10
17
  setSelectedTargets,
11
18
  setHostsSearchQuery,
19
+ setSelectedBookmark,
12
20
  setJobTemplateID,
13
21
  setTemplateValues,
14
22
  setAdvancedValues,
15
23
  }) => {
16
24
  const dispatch = useDispatch();
25
+ const bookmarks = useSelector(selectHostBookmarks);
26
+ const [pendingBookmarkId, setPendingBookmarkId] = useState(null);
27
+
28
+ useEffect(() => {
29
+ if (pendingBookmarkId === null || bookmarks.length === 0) return;
30
+ const bookmark = bookmarks.find(bm => bm.id === pendingBookmarkId);
31
+ if (bookmark) {
32
+ setSelectedBookmark({
33
+ id: bookmark.id,
34
+ name: bookmark.name,
35
+ query: bookmark.query,
36
+ });
37
+ setHostsSearchQuery(bookmark.query);
38
+ }
39
+ setPendingBookmarkId(null);
40
+ }, [bookmarks, pendingBookmarkId, setSelectedBookmark, setHostsSearchQuery]);
17
41
 
18
42
  useEffect(() => {
19
43
  if (Object.keys(fills).length) {
@@ -22,10 +46,12 @@ export const useAutoFill = ({
22
46
  search,
23
47
  feature,
24
48
  template_id: templateID,
49
+ bookmark_id: bookmarkId,
25
50
  ...rest
26
51
  } = { ...fills };
27
52
  setFills({});
28
53
  if (hostIds) {
54
+ setSelectedBookmark(null);
29
55
  const hostSearch = Array.isArray(hostIds)
30
56
  ? `id = ${hostIds.join(' or id = ')}`
31
57
  : `id = ${hostIds}`;
@@ -52,9 +78,34 @@ export const useAutoFill = ({
52
78
  })
53
79
  );
54
80
  }
55
- if ((search || search === '') && !hostIds?.length) {
81
+ if (bookmarkId) {
82
+ setSelectedTargets({
83
+ hosts: [],
84
+ hostCollections: [],
85
+ hostGroups: [],
86
+ });
87
+ const numericId = Number(bookmarkId);
88
+ if (bookmarks.length > 0) {
89
+ const bookmark = bookmarks.find(bm => bm.id === numericId);
90
+ if (bookmark) {
91
+ setSelectedBookmark({
92
+ id: bookmark.id,
93
+ name: bookmark.name,
94
+ query: bookmark.query,
95
+ });
96
+ setHostsSearchQuery(bookmark.query);
97
+ }
98
+ } else {
99
+ setPendingBookmarkId(numericId);
100
+ dispatch(
101
+ getBookmarks(hostsSearchProps.bookmarks.url, hostsController)
102
+ );
103
+ }
104
+ } else if ((search || search === '') && !hostIds?.length) {
56
105
  // replace an empty string search with a dummy search query to match all hosts
57
106
  // but only if search query was entered (based on presence of :search parameter)
107
+
108
+ setSelectedBookmark(null);
58
109
  const hostSearch = search === '' ? "name != ''" : search;
59
110
  setHostsSearchQuery(hostSearch);
60
111
  }
@@ -100,9 +151,11 @@ export const useAutoFill = ({
100
151
  setFills,
101
152
  setSelectedTargets,
102
153
  setHostsSearchQuery,
154
+ setSelectedBookmark,
103
155
  setJobTemplateID,
104
156
  setTemplateValues,
105
157
  setAdvancedValues,
106
158
  dispatch,
159
+ bookmarks,
107
160
  ]);
108
161
  };
@@ -125,7 +125,7 @@ describe('AdvancedFields', () => {
125
125
  const resourceSelectField = screen.getByLabelText(
126
126
  'adv resource select toggle'
127
127
  );
128
- const searchField = screen.getByPlaceholderText('Filter...');
128
+ const searchField = screen.getByPlaceholderText('Search');
129
129
  const dateField = screen.getByLabelText('adv date datepicker');
130
130
  const timeField = screen.getByLabelText('adv date timepicker');
131
131
 
@@ -403,6 +403,11 @@ describe('AdvancedFields', () => {
403
403
 
404
404
  jest.advanceTimersByTime(10000);
405
405
  });
406
- expect(newStore.getActions()).toMatchSnapshot('resource search');
406
+ const actions = newStore.getActions();
407
+ const resourceSearchAction = actions.filter(
408
+ action => action.key === 'ForemanTasksTask'
409
+ );
410
+ expect(resourceSearchAction).toHaveLength(2);
411
+ expect(String(resourceSearchAction[1].url)).toContain('name=some+search');
407
412
  });
408
413
  });
@@ -1,24 +1,47 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
+ import { useSelector } from 'react-redux';
3
4
  import SearchBar from 'foremanReact/components/SearchBar';
4
- import { getControllerSearchProps } from 'foremanReact/constants';
5
- import { hostsController, hostQuerySearchID } from '../../JobWizardConstants';
5
+ import { hostQuerySearchID, hostsSearchProps } from '../../JobWizardConstants';
6
+ import { selectHostBookmarks } from '../../JobWizardSelectors';
7
+
8
+ export const HostSearch = ({ value, setValue, onBookmarkMatch }) => {
9
+ const bookmarks = useSelector(selectHostBookmarks);
10
+
11
+ const handleSearchChange = search => {
12
+ setValue(search);
13
+ onBookmarkMatch(null);
14
+ };
15
+
16
+ const handleBookmarkSearch = query => {
17
+ const matched = bookmarks.find(
18
+ bookmark => bookmark.query && bookmark.query.trim() === query.trim()
19
+ );
20
+ if (matched) {
21
+ onBookmarkMatch({
22
+ id: matched.id,
23
+ name: matched.name,
24
+ query: matched.query,
25
+ });
26
+ } else {
27
+ onBookmarkMatch(null);
28
+ }
29
+ };
6
30
 
7
- export const HostSearch = ({ value, setValue }) => {
8
- const props = getControllerSearchProps(hostsController, hostQuerySearchID);
9
31
  return (
10
32
  <div className="foreman-search-field">
11
33
  <SearchBar
12
34
  data={{
13
- ...props,
35
+ ...hostsSearchProps,
14
36
  autocomplete: {
15
37
  id: hostQuerySearchID,
16
38
  url: '/hosts/auto_complete_search',
17
39
  searchQuery: value,
18
40
  },
19
41
  }}
20
- onSearch={null}
21
- onSearchChange={search => setValue(search)}
42
+ onSearch={handleBookmarkSearch}
43
+ onSearchChange={handleSearchChange}
44
+ bookmarksPosition="right"
22
45
  />
23
46
  </div>
24
47
  );
@@ -27,4 +50,5 @@ export const HostSearch = ({ value, setValue }) => {
27
50
  HostSearch.propTypes = {
28
51
  value: PropTypes.string.isRequired,
29
52
  setValue: PropTypes.func.isRequired,
53
+ onBookmarkMatch: PropTypes.func.isRequired,
30
54
  };
@@ -6,7 +6,12 @@ import * as api from 'foremanReact/redux/API';
6
6
  import * as routerSelectors from 'foremanReact/routes/RouterSelector';
7
7
  import { JobWizard } from '../../../JobWizard';
8
8
  import * as selectors from '../../../JobWizardSelectors';
9
- import { testSetup, mockApi, gqlMock } from '../../../__tests__/fixtures';
9
+ import {
10
+ testSetup,
11
+ mockApi,
12
+ gqlMock,
13
+ bookmarksList,
14
+ } from '../../../__tests__/fixtures';
10
15
 
11
16
  const store = testSetup(selectors, api);
12
17
  mockApi(api);
@@ -183,6 +188,95 @@ describe('Hosts', () => {
183
188
  expect(screen.queryAllByText('os=gnome')).toHaveLength(1);
184
189
  });
185
190
 
191
+ it('submits bookmark_id when search matches a bookmark', async () => {
192
+ const bookmark = bookmarksList[0];
193
+ routerSelectors.selectRouterLocation.mockImplementation(() => ({
194
+ search: '',
195
+ }));
196
+ const bookmarkStore = testSetup(selectors, api);
197
+ mockApi(api);
198
+
199
+ render(
200
+ <MockedProvider mocks={gqlMock} addTypename={false}>
201
+ <Provider store={bookmarkStore}>
202
+ <JobWizard />
203
+ </Provider>
204
+ </MockedProvider>
205
+ );
206
+
207
+ await act(async () => {
208
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
209
+ });
210
+ await act(async () => {
211
+ fireEvent.click(screen.getByRole('button', { name: 'host method' }));
212
+ });
213
+ await act(async () => {
214
+ fireEvent.click(screen.getByText('Search query'));
215
+ });
216
+
217
+ await act(async () => {
218
+ fireEvent.click(
219
+ screen.getByRole('button', { name: 'bookmarks dropdown toggle' })
220
+ );
221
+ });
222
+ await act(async () => {
223
+ fireEvent.click(screen.getByText(bookmark.name));
224
+ });
225
+
226
+ await act(async () => {
227
+ fireEvent.click(screen.getByText('Review details'));
228
+ });
229
+ await act(async () => {
230
+ fireEvent.click(screen.getByText('Submit'));
231
+ });
232
+
233
+ const submitAction = bookmarkStore
234
+ .getActions()
235
+ .find(action => action?.key === 'JOB_INVOCATION');
236
+ expect(submitAction).toBeDefined();
237
+ const { job_invocation: invocation } = submitAction.params;
238
+ expect(invocation.bookmark_id).toBe(bookmark.id);
239
+ expect(invocation.search_query).toBeNull();
240
+ });
241
+
242
+ it('does not submit bookmark_id when search does not match a bookmark', async () => {
243
+ const customQuery = 'some custom query';
244
+ routerSelectors.selectRouterLocation.mockImplementation(() => ({
245
+ search: `search=${encodeURIComponent(customQuery)}`,
246
+ }));
247
+ const customStore = testSetup(selectors, api);
248
+ mockApi(api);
249
+
250
+ render(
251
+ <MockedProvider mocks={gqlMock} addTypename={false}>
252
+ <Provider store={customStore}>
253
+ <JobWizard />
254
+ </Provider>
255
+ </MockedProvider>
256
+ );
257
+
258
+ await act(async () => {
259
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
260
+ });
261
+
262
+ expect(screen.queryAllByText(customQuery)).toHaveLength(1);
263
+
264
+ await act(async () => {
265
+ fireEvent.click(screen.getByText('Review details'));
266
+ });
267
+ await act(async () => {
268
+ fireEvent.click(screen.getByText('Submit'));
269
+ });
270
+
271
+ const submitAction = customStore
272
+ .getActions()
273
+ .find(action => action?.key === 'JOB_INVOCATION');
274
+ expect(submitAction).toBeDefined();
275
+ const { job_invocation: invocation } = submitAction.params;
276
+ expect(invocation.bookmark_id).toBeNull();
277
+ expect(invocation.search_query).toBe(customQuery);
278
+ });
279
+
186
280
  it('input fill from url', async () => {
187
281
  const inputText = 'test text';
188
282
  const advancedInputText = 'test adv text';
@@ -51,6 +51,7 @@ const HostsAndInputs = ({
51
51
  setSelected,
52
52
  hostsSearchQuery,
53
53
  setHostsSearchQuery,
54
+ setSelectedBookmark,
54
55
  }) => {
55
56
  const defaultHostMethod = hostsSearchQuery.length
56
57
  ? hostMethods.searchQuery
@@ -132,6 +133,7 @@ const HostsAndInputs = ({
132
133
 
133
134
  const clearSearch = () => {
134
135
  setHostsSearchQuery('');
136
+ setSelectedBookmark(null);
135
137
  };
136
138
  const [errorText, setErrorText] = useState(
137
139
  __('Please select at least one host')
@@ -156,6 +158,7 @@ const HostsAndInputs = ({
156
158
  className="target-method-select"
157
159
  toggleIcon={<FilterIcon />}
158
160
  fieldId="host_methods"
161
+ toggleAriaLabel={__('host method')}
159
162
  options={Object.values(hostMethods).filter(method => {
160
163
  if (method === hostMethods.hostCollections && !withKatello) {
161
164
  return false;
@@ -186,6 +189,7 @@ const HostsAndInputs = ({
186
189
  <HostSearch
187
190
  setValue={setHostsSearchQuery}
188
191
  value={hostsSearchQuery}
192
+ onBookmarkMatch={setSelectedBookmark}
189
193
  />
190
194
  )}
191
195
  {hostMethod === hostMethods.hosts && (
@@ -286,6 +290,7 @@ HostsAndInputs.propTypes = {
286
290
  setSelected: PropTypes.func.isRequired,
287
291
  hostsSearchQuery: PropTypes.string.isRequired,
288
292
  setHostsSearchQuery: PropTypes.func.isRequired,
293
+ setSelectedBookmark: PropTypes.func.isRequired,
289
294
  };
290
295
 
291
296
  export default HostsAndInputs;
@@ -55,6 +55,8 @@ const store = mockStore({
55
55
  subtotal: 3,
56
56
  },
57
57
  },
58
+ bookmarksPF4: {},
59
+ API: {},
58
60
  });
59
61
  jest.useFakeTimers();
60
62
 
@@ -2,6 +2,14 @@ import { post } from 'foremanReact/redux/API';
2
2
  import { repeatTypes, JOB_INVOCATION } from './JobWizardConstants';
3
3
  import { buildHostQuery } from './steps/HostsAndInputs/buildHostQuery';
4
4
 
5
+ const hasExplicitTargets = selectedTargets =>
6
+ selectedTargets.hosts.length > 0 ||
7
+ selectedTargets.hostCollections.length > 0 ||
8
+ selectedTargets.hostGroups.length > 0;
9
+
10
+ const shouldSendBookmark = (selectedBookmark, selectedTargets) =>
11
+ selectedBookmark && !hasExplicitTargets(selectedTargets);
12
+
5
13
  export const submit = ({
6
14
  jobTemplateID,
7
15
  templateValues,
@@ -9,6 +17,7 @@ export const submit = ({
9
17
  scheduleValue,
10
18
  selectedTargets,
11
19
  hostsSearchQuery,
20
+ selectedBookmark,
12
21
  location,
13
22
  organization,
14
23
  feature,
@@ -110,8 +119,12 @@ export const submit = ({
110
119
  concurrency_control: {
111
120
  concurrency_level: concurrencyLevel,
112
121
  },
113
- bookmark_id: null,
114
- search_query: buildHostQuery(selectedTargets, hostsSearchQuery),
122
+ bookmark_id: shouldSendBookmark(selectedBookmark, selectedTargets)
123
+ ? selectedBookmark.id
124
+ : null,
125
+ search_query: shouldSendBookmark(selectedBookmark, selectedTargets)
126
+ ? null
127
+ : buildHostQuery(selectedTargets, hostsSearchQuery),
115
128
  description_format: description,
116
129
  execution_timeout_interval: timeoutToKill,
117
130
  feature,
@@ -14,6 +14,7 @@ exports[`TargetingHostsPage renders 1`] = `
14
14
  md={6}
15
15
  >
16
16
  <SearchBar
17
+ bookmarksPosition="left"
17
18
  data={
18
19
  Object {
19
20
  "autocomplete": Object {
@@ -31,9 +32,12 @@ exports[`TargetingHostsPage renders 1`] = `
31
32
  "controller": "hosts",
32
33
  }
33
34
  }
35
+ initialQuery=""
36
+ name={null}
34
37
  onBookmarkClick={[Function]}
35
- onChange={[Function]}
36
38
  onSearch={[Function]}
39
+ onSearchChange={[Function]}
40
+ restrictedSearchQuery={[Function]}
37
41
  />
38
42
  </Col>
39
43
  </Row>
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.6.3
4
+ version: 16.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-15 00:00:00.000000000 Z
10
+ date: 2026-06-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -354,6 +354,7 @@ files:
354
354
  - test/graphql/queries/job_invocation_query_test.rb
355
355
  - test/graphql/queries/job_invocations_query_test.rb
356
356
  - test/helpers/remote_execution_helper_test.rb
357
+ - test/integration/job_wizard_category_js_test.rb
357
358
  - test/support/remote_execution_helper.rb
358
359
  - test/test_plugin_helper.rb
359
360
  - test/unit/actions/run_host_job_test.rb
@@ -420,7 +421,6 @@ files:
420
421
  - webpack/JobWizard/steps/AdvancedFields/DescriptionField.js
421
422
  - webpack/JobWizard/steps/AdvancedFields/Fields.js
422
423
  - webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js
423
- - webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap
424
424
  - webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js
425
425
  - webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js
426
426
  - webpack/JobWizard/steps/CategoryAndTemplate/index.js
@@ -475,7 +475,6 @@ files:
475
475
  - webpack/__mocks__/foremanReact/components/Head/index.js
476
476
  - webpack/__mocks__/foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState.js
477
477
  - webpack/__mocks__/foremanReact/components/Pagination.js
478
- - webpack/__mocks__/foremanReact/components/SearchBar.js
479
478
  - webpack/__mocks__/foremanReact/components/ToastsList/index.js
480
479
  - webpack/__mocks__/foremanReact/components/common/ActionButtons/ActionButtons.js
481
480
  - webpack/__mocks__/foremanReact/constants.js
@@ -1,90 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`AdvancedFields search resources action: resource search 1`] = `
4
- Array [
5
- Object {
6
- "key": "JOB_CATEGORIES",
7
- "type": "get",
8
- "url": "/ui_job_wizard/categories",
9
- },
10
- Object {
11
- "key": "HOST_IDS",
12
- "params": Object {
13
- "search": "id = 105 or id = 37",
14
- },
15
- "type": "get",
16
- "url": "/api/hosts",
17
- },
18
- Object {
19
- "key": "JOB_TEMPLATES",
20
- "type": "get",
21
- "url": URI {
22
- "_deferred_build": true,
23
- "_parts": Object {
24
- "duplicateQueryParameters": false,
25
- "escapeQuerySpace": true,
26
- "fragment": null,
27
- "hostname": null,
28
- "password": null,
29
- "path": "foreman/api/v2/job_templates",
30
- "port": null,
31
- "preventInvalidHostname": false,
32
- "protocol": null,
33
- "query": "search=job_category%3D%22Ansible+Commands%22&per_page=all",
34
- "urn": null,
35
- "username": null,
36
- },
37
- "_string": "",
38
- },
39
- },
40
- Object {
41
- "key": "JOB_TEMPLATE",
42
- "type": "get",
43
- "url": "/ui_job_wizard/template/178",
44
- },
45
- Object {
46
- "key": "ForemanTasksTask",
47
- "type": "get",
48
- "url": URI {
49
- "_deferred_build": true,
50
- "_parts": Object {
51
- "duplicateQueryParameters": false,
52
- "escapeQuerySpace": true,
53
- "fragment": null,
54
- "hostname": null,
55
- "password": null,
56
- "path": "/ui_job_wizard/resources",
57
- "port": null,
58
- "preventInvalidHostname": false,
59
- "protocol": null,
60
- "query": "resource=ForemanTasks%3A%3ATask",
61
- "urn": null,
62
- "username": null,
63
- },
64
- "_string": "",
65
- },
66
- },
67
- Object {
68
- "key": "ForemanTasksTask",
69
- "type": "get",
70
- "url": URI {
71
- "_deferred_build": true,
72
- "_parts": Object {
73
- "duplicateQueryParameters": false,
74
- "escapeQuerySpace": true,
75
- "fragment": null,
76
- "hostname": null,
77
- "password": null,
78
- "path": "/ui_job_wizard/resources",
79
- "port": null,
80
- "preventInvalidHostname": false,
81
- "protocol": null,
82
- "query": "resource=ForemanTasks%3A%3ATask&name=some+search",
83
- "urn": null,
84
- "username": null,
85
- },
86
- "_string": "",
87
- },
88
- },
89
- ]
90
- `;
@@ -1,19 +0,0 @@
1
- import React from 'react';
2
- import PropTypes from 'prop-types';
3
-
4
- const SearchBar = ({ onChange }) => (
5
- <input
6
- className="foreman-search"
7
- onChange={onChange}
8
- placeholder="Filter..."
9
- />
10
- );
11
- export default SearchBar;
12
-
13
- SearchBar.propTypes = {
14
- onChange: PropTypes.func,
15
- };
16
-
17
- SearchBar.defaultProps = {
18
- onChange: () => null,
19
- };