foreman_remote_execution 4.5.6 → 5.0.0

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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby_ci.yml +7 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/app/controllers/api/v2/job_invocations_controller.rb +16 -1
  5. data/app/controllers/ui_job_wizard_controller.rb +16 -4
  6. data/app/graphql/mutations/job_invocations/create.rb +43 -0
  7. data/app/graphql/types/job_invocation.rb +16 -0
  8. data/app/graphql/types/job_invocation_input.rb +13 -0
  9. data/app/graphql/types/recurrence_input.rb +8 -0
  10. data/app/graphql/types/scheduling_input.rb +6 -0
  11. data/app/graphql/types/targeting_enum.rb +7 -0
  12. data/app/lib/actions/remote_execution/run_host_job.rb +6 -1
  13. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  14. data/app/mailers/rex_job_mailer.rb +15 -0
  15. data/app/models/concerns/foreman_remote_execution/host_extensions.rb +8 -0
  16. data/app/models/job_invocation.rb +4 -0
  17. data/app/models/job_invocation_composer.rb +21 -13
  18. data/app/models/job_template.rb +1 -1
  19. data/app/models/remote_execution_provider.rb +17 -2
  20. data/app/models/rex_mail_notification.rb +13 -0
  21. data/app/models/targeting.rb +2 -2
  22. data/app/services/ui_notifications/remote_execution_jobs/base_job_finish.rb +2 -1
  23. data/app/views/dashboard/_latest-jobs.html.erb +21 -0
  24. data/app/views/job_invocations/refresh.js.erb +1 -0
  25. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  26. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  27. data/app/views/template_invocations/show.html.erb +2 -1
  28. data/config/routes.rb +1 -0
  29. data/db/migrate/20210816100932_rex_setting_category_to_dsl.rb +5 -0
  30. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  31. data/db/seeds.d/95-mail_notifications.rb +24 -0
  32. data/foreman_remote_execution.gemspec +2 -4
  33. data/lib/foreman_remote_execution/engine.rb +114 -6
  34. data/lib/foreman_remote_execution/version.rb +1 -1
  35. data/package.json +6 -6
  36. data/test/functional/api/v2/job_invocations_controller_test.rb +20 -0
  37. data/test/functional/cockpit_controller_test.rb +0 -1
  38. data/test/graphql/mutations/job_invocations/create.rb +58 -0
  39. data/test/graphql/queries/job_invocation_query_test.rb +31 -0
  40. data/test/graphql/queries/job_invocations_query_test.rb +35 -0
  41. data/test/helpers/remote_execution_helper_test.rb +0 -1
  42. data/test/unit/actions/run_host_job_test.rb +21 -0
  43. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  44. data/test/unit/concerns/host_extensions_test.rb +40 -7
  45. data/test/unit/input_template_renderer_test.rb +1 -89
  46. data/test/unit/job_invocation_composer_test.rb +4 -17
  47. data/test/unit/job_invocation_report_template_test.rb +16 -13
  48. data/test/unit/job_template_effective_user_test.rb +0 -4
  49. data/test/unit/remote_execution_provider_test.rb +34 -4
  50. data/test/unit/targeting_test.rb +68 -1
  51. data/webpack/JobWizard/JobWizard.js +106 -15
  52. data/webpack/JobWizard/JobWizard.scss +73 -39
  53. data/webpack/JobWizard/JobWizardConstants.js +36 -0
  54. data/webpack/JobWizard/JobWizardSelectors.js +32 -0
  55. data/webpack/JobWizard/__tests__/fixtures.js +81 -6
  56. data/webpack/JobWizard/__tests__/integration.test.js +26 -15
  57. data/webpack/JobWizard/__tests__/validation.test.js +141 -0
  58. data/webpack/JobWizard/autofill.js +38 -0
  59. data/webpack/JobWizard/index.js +7 -0
  60. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +7 -4
  61. data/webpack/JobWizard/steps/AdvancedFields/DescriptionField.js +32 -9
  62. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +216 -12
  63. data/webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap +82 -0
  64. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +1 -0
  65. data/webpack/JobWizard/steps/CategoryAndTemplate/index.js +3 -2
  66. data/webpack/JobWizard/steps/HostsAndInputs/HostPreviewModal.js +62 -0
  67. data/webpack/JobWizard/steps/HostsAndInputs/HostSearch.js +54 -0
  68. data/webpack/JobWizard/steps/HostsAndInputs/SelectAPI.js +33 -0
  69. data/webpack/JobWizard/steps/HostsAndInputs/SelectGQL.js +52 -0
  70. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +82 -7
  71. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/HostsAndInputs.test.js +151 -0
  72. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +7 -4
  73. data/webpack/JobWizard/steps/HostsAndInputs/buildHostQuery.js +18 -0
  74. data/webpack/JobWizard/steps/HostsAndInputs/hostgroups.gql +8 -0
  75. data/webpack/JobWizard/steps/HostsAndInputs/hosts.gql +8 -0
  76. data/webpack/JobWizard/steps/HostsAndInputs/index.js +182 -34
  77. data/webpack/JobWizard/steps/ReviewDetails/index.js +193 -0
  78. data/webpack/JobWizard/steps/Schedule/PurposeField.js +31 -0
  79. data/webpack/JobWizard/steps/Schedule/QueryType.js +46 -43
  80. data/webpack/JobWizard/steps/Schedule/RepeatCron.js +53 -0
  81. data/webpack/JobWizard/steps/Schedule/RepeatDaily.js +37 -0
  82. data/webpack/JobWizard/steps/Schedule/RepeatHour.js +54 -0
  83. data/webpack/JobWizard/steps/Schedule/RepeatMonth.js +46 -0
  84. data/webpack/JobWizard/steps/Schedule/RepeatOn.js +95 -31
  85. data/webpack/JobWizard/steps/Schedule/RepeatWeek.js +70 -0
  86. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  87. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +78 -23
  88. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +402 -0
  89. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +20 -10
  90. data/webpack/JobWizard/steps/Schedule/index.js +153 -19
  91. data/webpack/JobWizard/steps/form/DateTimePicker.js +126 -0
  92. data/webpack/JobWizard/steps/form/FormHelpers.js +4 -0
  93. data/webpack/JobWizard/steps/form/Formatter.js +39 -8
  94. data/webpack/JobWizard/steps/form/NumberInput.js +3 -2
  95. data/webpack/JobWizard/steps/form/ResourceSelect.js +29 -0
  96. data/webpack/JobWizard/steps/form/SearchSelect.js +121 -0
  97. data/webpack/JobWizard/steps/form/SelectField.js +14 -3
  98. data/webpack/JobWizard/steps/form/__tests__/SelectSearch.test.js +33 -0
  99. data/webpack/JobWizard/submit.js +120 -0
  100. data/webpack/JobWizard/validation.js +53 -0
  101. data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js +2 -0
  102. data/webpack/__mocks__/foremanReact/common/I18n.js +2 -0
  103. data/webpack/__mocks__/foremanReact/components/AutoComplete/AutoCompleteActions.js +1 -0
  104. data/webpack/__mocks__/foremanReact/components/AutoComplete/AutoCompleteConstants.js +1 -0
  105. data/webpack/__mocks__/foremanReact/routes/RouterSelector.js +1 -0
  106. data/webpack/helpers.js +1 -0
  107. data/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js +43 -0
  108. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +73 -66
  109. data/webpack/react_app/components/RecentJobsCard/RecentJobsTable.js +98 -0
  110. data/webpack/react_app/components/RecentJobsCard/constants.js +11 -0
  111. data/webpack/react_app/components/RecentJobsCard/styles.scss +11 -0
  112. data/webpack/react_app/extend/fillRecentJobsCard.js +1 -1
  113. metadata +56 -23
  114. data/app/models/setting/remote_execution.rb +0 -88
  115. data/webpack/JobWizard/steps/AdvancedFields/__tests__/DescriptionField.test.js +0 -23
  116. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js +0 -37
  117. data/webpack/JobWizard/steps/form/__tests__/Formatter.test.js.example +0 -76
  118. data/webpack/react_app/components/RecentJobsCard/styles.css +0 -15
@@ -5,7 +5,6 @@ import { Form } from '@patternfly/react-core';
5
5
  import {
6
6
  selectEffectiveUser,
7
7
  selectAdvancedTemplateInputs,
8
- selectTemplateInputs,
9
8
  } from '../../JobWizardSelectors';
10
9
  import {
11
10
  EffectiveUserField,
@@ -22,10 +21,13 @@ import { DescriptionField } from './DescriptionField';
22
21
  import { WIZARD_TITLES } from '../../JobWizardConstants';
23
22
  import { WizardTitle } from '../form/WizardTitle';
24
23
 
25
- export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
24
+ export const AdvancedFields = ({
25
+ templateValues,
26
+ advancedValues,
27
+ setAdvancedValues,
28
+ }) => {
26
29
  const effectiveUser = useSelector(selectEffectiveUser);
27
30
  const advancedTemplateInputs = useSelector(selectAdvancedTemplateInputs);
28
- const templateInputs = useSelector(selectTemplateInputs);
29
31
  return (
30
32
  <>
31
33
  <WizardTitle
@@ -49,7 +51,7 @@ export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
49
51
  />
50
52
  )}
51
53
  <DescriptionField
52
- inputs={templateInputs}
54
+ inputValues={{ ...templateValues, ...advancedValues.templateValues }}
53
55
  value={advancedValues.description}
54
56
  setValue={newValue => setAdvancedValues({ description: newValue })}
55
57
  />
@@ -117,5 +119,6 @@ export const AdvancedFields = ({ advancedValues, setAdvancedValues }) => {
117
119
  AdvancedFields.propTypes = {
118
120
  advancedValues: PropTypes.object.isRequired,
119
121
  setAdvancedValues: PropTypes.func.isRequired,
122
+ templateValues: PropTypes.object.isRequired,
120
123
  };
121
124
  export default AdvancedFields;
@@ -1,10 +1,19 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useSelector } from 'react-redux';
2
3
  import PropTypes from 'prop-types';
3
- import { FormGroup, TextInput, Button } from '@patternfly/react-core';
4
+ import { FormGroup, TextInput, Tooltip, Button } from '@patternfly/react-core';
4
5
  import { translate as __ } from 'foremanReact/common/I18n';
6
+ import {
7
+ selectTemplateInputs,
8
+ selectAdvancedTemplateInputs,
9
+ } from '../../JobWizardSelectors';
5
10
 
6
- export const DescriptionField = ({ inputs, value, setValue }) => {
7
- const generateDesc = () => {
11
+ export const DescriptionField = ({ inputValues, value, setValue }) => {
12
+ const inputs = [
13
+ ...useSelector(selectTemplateInputs),
14
+ ...useSelector(selectAdvancedTemplateInputs),
15
+ ].map(input => input.name);
16
+ const generateDesc = useCallback(() => {
8
17
  let newDesc = value;
9
18
  if (value) {
10
19
  const re = new RegExp('%\\{([^\\}]+)\\}', 'gm');
@@ -15,16 +24,19 @@ export const DescriptionField = ({ inputs, value, setValue }) => {
15
24
  results.forEach(result => {
16
25
  newDesc = newDesc.replace(
17
26
  result.text,
18
- // TODO: Replace with the value of the input from Target Hosts step
19
- inputs.find(input => input.name === result.name)?.name || result.text
27
+ inputValues[result.name] ||
28
+ (inputs.includes(result.name) ? '' : result.text)
20
29
  );
21
30
  });
22
31
  }
23
32
  return newDesc;
24
- };
33
+ }, [inputs, value, inputValues]);
25
34
  const [generatedDesc, setGeneratedDesc] = useState(generateDesc());
26
35
  const [isPreview, setIsPreview] = useState(true);
27
36
 
37
+ useEffect(() => {
38
+ setGeneratedDesc(generateDesc());
39
+ }, [generateDesc]);
28
40
  const togglePreview = () => {
29
41
  setGeneratedDesc(generateDesc());
30
42
  setIsPreview(v => !v);
@@ -43,9 +55,20 @@ export const DescriptionField = ({ inputs, value, setValue }) => {
43
55
  }
44
56
  >
45
57
  {isPreview ? (
46
- <TextInput id="description-preview" value={generatedDesc} isDisabled />
58
+ <Tooltip content={generatedDesc}>
59
+ <div>
60
+ {/* div wrapper so the tooltip will be shown in chrome */}
61
+ <TextInput
62
+ aria-label="description preview"
63
+ id="description-preview"
64
+ value={generatedDesc}
65
+ isDisabled
66
+ />
67
+ </div>
68
+ </Tooltip>
47
69
  ) : (
48
70
  <TextInput
71
+ aria-label="description edit"
49
72
  type="text"
50
73
  autoComplete="description"
51
74
  id="description"
@@ -58,7 +81,7 @@ export const DescriptionField = ({ inputs, value, setValue }) => {
58
81
  };
59
82
 
60
83
  DescriptionField.propTypes = {
61
- inputs: PropTypes.array.isRequired,
84
+ inputValues: PropTypes.object.isRequired,
62
85
  value: PropTypes.string,
63
86
  setValue: PropTypes.func.isRequired,
64
87
  };
@@ -1,14 +1,19 @@
1
+ /* eslint-disable max-lines */
1
2
  import React from 'react';
2
3
  import { Provider } from 'react-redux';
3
4
  import { mount } from '@theforeman/test';
4
5
  import { fireEvent, screen, render, act } from '@testing-library/react';
6
+ import { MockedProvider } from '@apollo/client/testing';
5
7
  import * as api from 'foremanReact/redux/API';
6
8
  import { JobWizard } from '../../../JobWizard';
7
9
  import * as selectors from '../../../JobWizardSelectors';
8
10
  import {
9
- jobTemplateResponse as jobTemplate,
11
+ jobTemplateResponse,
12
+ jobTemplate,
10
13
  testSetup,
11
14
  mockApi,
15
+ jobCategories,
16
+ gqlMock,
12
17
  } from '../../../__tests__/fixtures';
13
18
  import { WIZARD_TITLES } from '../../../JobWizardConstants';
14
19
 
@@ -18,14 +23,16 @@ mockApi(api);
18
23
  jest.spyOn(selectors, 'selectEffectiveUser');
19
24
 
20
25
  selectors.selectEffectiveUser.mockImplementation(
21
- () => jobTemplate.effective_user
26
+ () => jobTemplateResponse.effective_user
22
27
  );
23
28
  describe('AdvancedFields', () => {
24
29
  it('should save data between steps for advanced fields', async () => {
25
30
  const wrapper = mount(
26
- <Provider store={store}>
27
- <JobWizard advancedValues={{}} setAdvancedValues={jest.fn()} />
28
- </Provider>
31
+ <MockedProvider mocks={gqlMock} addTypename={false}>
32
+ <Provider store={store}>
33
+ <JobWizard />
34
+ </Provider>
35
+ </MockedProvider>
29
36
  );
30
37
  // setup
31
38
  wrapper.find('.pf-c-button.pf-c-select__toggle-button').simulate('click');
@@ -78,9 +85,11 @@ describe('AdvancedFields', () => {
78
85
  });
79
86
  it('fill template fields', async () => {
80
87
  render(
81
- <Provider store={store}>
82
- <JobWizard />
83
- </Provider>
88
+ <MockedProvider mocks={gqlMock} addTypename={false}>
89
+ <Provider store={store}>
90
+ <JobWizard />
91
+ </Provider>
92
+ </MockedProvider>
84
93
  );
85
94
  await act(async () => {
86
95
  fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
@@ -91,7 +100,10 @@ describe('AdvancedFields', () => {
91
100
  const textField = screen.getByLabelText('adv plain hidden', {
92
101
  selector: 'textarea',
93
102
  });
94
- const selectField = screen.getByText('option 1');
103
+ const selectField = screen.getByLabelText('adv plain select toggle');
104
+ const resourceSelectField = screen.getByLabelText(
105
+ 'adv resource select toggle'
106
+ );
95
107
  const searchField = screen.getByPlaceholderText('Filter...');
96
108
  const dateField = screen.getByLabelText('adv date', {
97
109
  selector: 'input',
@@ -101,6 +113,10 @@ describe('AdvancedFields', () => {
101
113
  await act(async () => {
102
114
  await fireEvent.click(screen.getByText('option 2'));
103
115
  fireEvent.click(screen.getAllByText(WIZARD_TITLES.advanced)[0]); // to remove focus
116
+
117
+ fireEvent.click(resourceSelectField);
118
+ await fireEvent.click(screen.getByText('resource2'));
119
+
104
120
  await fireEvent.change(textField, {
105
121
  target: { value: textValue },
106
122
  });
@@ -134,12 +150,16 @@ describe('AdvancedFields', () => {
134
150
  expect(dateField.value).toBe(dateValue);
135
151
  expect(screen.queryAllByText('option 1')).toHaveLength(0);
136
152
  expect(screen.queryAllByText('option 2')).toHaveLength(1);
153
+ expect(screen.queryAllByDisplayValue('resource1')).toHaveLength(0);
154
+ expect(screen.queryAllByDisplayValue('resource2')).toHaveLength(1);
137
155
  });
138
156
  it('fill defaults into fields', async () => {
139
157
  render(
140
- <Provider store={store}>
141
- <JobWizard />
142
- </Provider>
158
+ <MockedProvider mocks={gqlMock} addTypename={false}>
159
+ <Provider store={store}>
160
+ <JobWizard />
161
+ </Provider>
162
+ </MockedProvider>
143
163
  );
144
164
  await act(async () => {
145
165
  fireEvent.click(screen.getByText('Advanced Fields'));
@@ -155,5 +175,189 @@ describe('AdvancedFields', () => {
155
175
  selector: 'input',
156
176
  }).value
157
177
  ).toBe('2');
178
+
179
+ expect(
180
+ screen.getByLabelText('description preview', {
181
+ selector: 'input',
182
+ }).value
183
+ ).toBe(
184
+ 'template1 with inputs adv plain hidden="Default val" adv plain select="" adv resource select="" adv search="" adv date="" plain hidden="Default val"'
185
+ );
186
+ });
187
+ it('DescriptionField', async () => {
188
+ render(
189
+ <Provider store={store}>
190
+ <JobWizard />
191
+ </Provider>
192
+ );
193
+ await act(async () => {
194
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
195
+ });
196
+
197
+ const textField = screen.getByLabelText('adv plain hidden', {
198
+ selector: 'textarea',
199
+ });
200
+ await act(async () => {
201
+ await fireEvent.change(textField, {
202
+ target: { value: 'test command' },
203
+ });
204
+ });
205
+ const descriptionValue = 'Run %{adv plain hidden} %{wrong command name}';
206
+
207
+ await act(async () => {
208
+ fireEvent.click(screen.getByText('Edit job description template'));
209
+ });
210
+
211
+ const editText = screen.getByLabelText('description edit', {
212
+ selector: 'input',
213
+ });
214
+ await fireEvent.change(editText, {
215
+ target: { value: descriptionValue },
216
+ });
217
+ await act(async () => {
218
+ fireEvent.click(screen.getByText('Preview job description'));
219
+ });
220
+ expect(
221
+ screen.getByLabelText('description preview', {
222
+ selector: 'input',
223
+ }).value
224
+ ).toBe('Run test command %{wrong command name}');
225
+ });
226
+
227
+ it('DescriptionField with no inputs', async () => {
228
+ jest.spyOn(api, 'get');
229
+
230
+ jest.spyOn(selectors, 'selectTemplateInputs');
231
+ jest.spyOn(selectors, 'selectAdvancedTemplateInputs');
232
+ selectors.selectTemplateInputs.mockImplementation(() => []);
233
+ selectors.selectAdvancedTemplateInputs.mockImplementation(() => []);
234
+ api.get.mockImplementation(({ handleSuccess, ...action }) => {
235
+ if (action.key === 'JOB_CATEGORIES') {
236
+ handleSuccess &&
237
+ handleSuccess({ data: { job_categories: jobCategories } });
238
+ } else if (action.key === 'JOB_TEMPLATE') {
239
+ handleSuccess &&
240
+ handleSuccess({
241
+ data: {
242
+ ...jobTemplateResponse,
243
+ advanced_template_inputs: [],
244
+ template_inputs: [],
245
+ },
246
+ });
247
+ } else if (action.key === 'JOB_TEMPLATES') {
248
+ handleSuccess &&
249
+ handleSuccess({
250
+ data: { results: [jobTemplate] },
251
+ });
252
+ }
253
+ return { type: 'get', ...action };
254
+ });
255
+ render(
256
+ <Provider store={store}>
257
+ <JobWizard />
258
+ </Provider>
259
+ );
260
+ await act(async () => {
261
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
262
+ });
263
+ expect(
264
+ screen.getByLabelText('description preview', {
265
+ selector: 'input',
266
+ }).value
267
+ ).toBe('template1');
268
+ });
269
+
270
+ it('DescriptionField with description_format', async () => {
271
+ jest.spyOn(api, 'get');
272
+ jest.spyOn(selectors, 'selectTemplateInputs');
273
+ selectors.selectTemplateInputs.mockImplementation(() => [
274
+ {
275
+ name: 'command',
276
+ required: true,
277
+ input_type: 'user',
278
+ description: 'some Description',
279
+ advanced: true,
280
+ value_type: 'plain',
281
+ resource_type: 'ansible_roles',
282
+ default: 'Default val',
283
+ hidden_value: true,
284
+ },
285
+ ]);
286
+ api.get.mockImplementation(({ handleSuccess, ...action }) => {
287
+ if (action.key === 'JOB_CATEGORIES') {
288
+ handleSuccess &&
289
+ handleSuccess({ data: { job_categories: jobCategories } });
290
+ } else if (action.key === 'JOB_TEMPLATE') {
291
+ handleSuccess &&
292
+ handleSuccess({
293
+ data: {
294
+ ...jobTemplateResponse,
295
+ job_template: {
296
+ ...jobTemplateResponse.jobTemplate,
297
+ description_format: 'Run %{command}',
298
+ },
299
+
300
+ template_inputs: [
301
+ {
302
+ name: 'command',
303
+ required: true,
304
+ input_type: 'user',
305
+ description: 'some Description',
306
+ advanced: true,
307
+ value_type: 'plain',
308
+ resource_type: 'ansible_roles',
309
+ default: 'Default val',
310
+ hidden_value: true,
311
+ },
312
+ ],
313
+ },
314
+ });
315
+ } else if (action.key === 'JOB_TEMPLATES') {
316
+ handleSuccess &&
317
+ handleSuccess({
318
+ data: { results: [jobTemplate] },
319
+ });
320
+ }
321
+ return { type: 'get', ...action };
322
+ });
323
+ render(
324
+ <Provider store={store}>
325
+ <JobWizard />
326
+ </Provider>
327
+ );
328
+ await act(async () => {
329
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
330
+ });
331
+ expect(
332
+ screen.getByLabelText('description preview', {
333
+ selector: 'input',
334
+ }).value
335
+ ).toBe('Run Default val');
336
+ });
337
+
338
+ it('search resources action', async () => {
339
+ jest.useFakeTimers();
340
+ mockApi(api);
341
+ const newStore = testSetup(selectors, api);
342
+ render(
343
+ <Provider store={newStore}>
344
+ <JobWizard />
345
+ </Provider>
346
+ );
347
+ await act(async () => {
348
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
349
+ });
350
+ const resourceSelectField = screen.getByLabelText(
351
+ 'adv resource select typeahead input'
352
+ );
353
+
354
+ await act(async () => {
355
+ await fireEvent.change(resourceSelectField, {
356
+ target: { value: 'some search' },
357
+ });
358
+
359
+ await jest.runAllTimers();
360
+ });
361
+ expect(newStore.getActions()).toMatchSnapshot('resource search');
158
362
  });
159
363
  });
@@ -0,0 +1,82 @@
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": "JOB_TEMPLATES",
12
+ "type": "get",
13
+ "url": URI {
14
+ "_deferred_build": true,
15
+ "_parts": Object {
16
+ "duplicateQueryParameters": false,
17
+ "escapeQuerySpace": true,
18
+ "fragment": null,
19
+ "hostname": null,
20
+ "password": null,
21
+ "path": "foreman/api/v2/job_templates",
22
+ "port": null,
23
+ "preventInvalidHostname": false,
24
+ "protocol": null,
25
+ "query": "search=job_category%3D%22Ansible+Commands%22&per_page=all",
26
+ "urn": null,
27
+ "username": null,
28
+ },
29
+ "_string": "",
30
+ },
31
+ },
32
+ Object {
33
+ "key": "JOB_TEMPLATE",
34
+ "type": "get",
35
+ "url": "/ui_job_wizard/template/178",
36
+ },
37
+ Object {
38
+ "key": "ForemanTasksTask",
39
+ "type": "get",
40
+ "url": URI {
41
+ "_deferred_build": true,
42
+ "_parts": Object {
43
+ "duplicateQueryParameters": false,
44
+ "escapeQuerySpace": true,
45
+ "fragment": null,
46
+ "hostname": null,
47
+ "password": null,
48
+ "path": "/ui_job_wizard/resources",
49
+ "port": null,
50
+ "preventInvalidHostname": false,
51
+ "protocol": null,
52
+ "query": "resource=ForemanTasks%3A%3ATask&name=",
53
+ "urn": null,
54
+ "username": null,
55
+ },
56
+ "_string": "",
57
+ },
58
+ },
59
+ Object {
60
+ "key": "ForemanTasksTask",
61
+ "type": "get",
62
+ "url": URI {
63
+ "_deferred_build": true,
64
+ "_parts": Object {
65
+ "duplicateQueryParameters": false,
66
+ "escapeQuerySpace": true,
67
+ "fragment": null,
68
+ "hostname": null,
69
+ "password": null,
70
+ "path": "/ui_job_wizard/resources",
71
+ "port": null,
72
+ "preventInvalidHostname": false,
73
+ "protocol": null,
74
+ "query": "resource=ForemanTasks%3A%3ATask&name=some+search",
75
+ "urn": null,
76
+ "username": null,
77
+ },
78
+ "_string": "",
79
+ },
80
+ },
81
+ ]
82
+ `;
@@ -53,6 +53,7 @@ export const CategoryAndTemplate = ({
53
53
  value={selectedCategory}
54
54
  placeholderText={categoryError ? __('Error') : ''}
55
55
  isDisabled={!!categoryError}
56
+ isRequired
56
57
  />
57
58
  <GroupedSelectField
58
59
  label={__('Job template')}
@@ -54,10 +54,11 @@ const ConnectedCategoryAndTemplate = ({
54
54
  search: `job_category="${category}"`,
55
55
  per_page: 'all',
56
56
  }),
57
- handleSuccess: response =>
57
+ handleSuccess: response => {
58
58
  setJobTemplate(
59
59
  Number(filterJobTemplates(response?.data?.results)[0]?.id) || null
60
- ),
60
+ );
61
+ },
61
62
  })
62
63
  );
63
64
  }
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useSelector } from 'react-redux';
4
+ import URI from 'urijs';
5
+ import { List, ListItem, Modal, Button } from '@patternfly/react-core';
6
+ import { translate as __, sprintf } from 'foremanReact/common/I18n';
7
+ import { foremanUrl } from 'foremanReact/common/helpers';
8
+ import { selectHosts, selectHostCount } from '../../JobWizardSelectors';
9
+ import { HOSTS_TO_PREVIEW_AMOUNT } from '../../JobWizardConstants';
10
+
11
+ export const HostPreviewModal = ({ isOpen, setIsOpen, searchQuery }) => {
12
+ const hosts = useSelector(selectHosts);
13
+ const hostsCount = useSelector(selectHostCount);
14
+ const url = new URI(foremanUrl('/hosts'));
15
+
16
+ return (
17
+ <Modal
18
+ title={__('Preview Hosts')}
19
+ isOpen={isOpen}
20
+ onClose={() => setIsOpen(false)}
21
+ appendTo={() => document.getElementsByClassName('job-wizard')[0]}
22
+ >
23
+ <List isPlain>
24
+ {hosts.map(host => (
25
+ <ListItem key={host}>
26
+ <Button
27
+ component="a"
28
+ href={foremanUrl(`/hosts/${host}`)}
29
+ variant="link"
30
+ target="_blank"
31
+ rel="noreferrer"
32
+ >
33
+ {host}
34
+ </Button>
35
+ </ListItem>
36
+ ))}
37
+ {hostsCount > HOSTS_TO_PREVIEW_AMOUNT && (
38
+ <ListItem>
39
+ <Button
40
+ component="a"
41
+ href={url.addSearch({ search: searchQuery })}
42
+ variant="link"
43
+ target="_blank"
44
+ rel="noreferrer"
45
+ >
46
+ {sprintf(
47
+ __('...and %s more'),
48
+ hostsCount - HOSTS_TO_PREVIEW_AMOUNT
49
+ )}
50
+ </Button>
51
+ </ListItem>
52
+ )}
53
+ </List>
54
+ </Modal>
55
+ );
56
+ };
57
+
58
+ HostPreviewModal.propTypes = {
59
+ isOpen: PropTypes.bool.isRequired,
60
+ setIsOpen: PropTypes.func.isRequired,
61
+ searchQuery: PropTypes.string.isRequired,
62
+ };
@@ -0,0 +1,54 @@
1
+ import React, { useEffect } from 'react';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import PropTypes from 'prop-types';
4
+ import SearchBar from 'foremanReact/components/SearchBar';
5
+ import { getControllerSearchProps } from 'foremanReact/constants';
6
+ import { getResults } from 'foremanReact/components/AutoComplete/AutoCompleteActions';
7
+ import { TRIGGERS } from 'foremanReact/components/AutoComplete/AutoCompleteConstants';
8
+ import { hostsController, hostQuerySearchID } from '../../JobWizardConstants';
9
+ import { noop } from '../../../helpers';
10
+
11
+ export const HostSearch = ({ value, setValue }) => {
12
+ const searchQuery = useSelector(
13
+ state => state.autocomplete?.hostsSearch?.searchQuery
14
+ );
15
+ useEffect(() => {
16
+ setValue(searchQuery || '');
17
+ }, [setValue, searchQuery]);
18
+ const dispatch = useDispatch();
19
+ const setSearch = newSearchQuery => {
20
+ dispatch(
21
+ getResults({
22
+ url: '/hosts/auto_complete_search',
23
+ searchQuery: newSearchQuery,
24
+ controller: 'hostsController',
25
+ trigger: TRIGGERS.INPUT_CHANGE,
26
+ id: hostQuerySearchID,
27
+ })
28
+ );
29
+ };
30
+
31
+ const props = getControllerSearchProps(hostsController, hostQuerySearchID);
32
+ return (
33
+ <div className="foreman-search-field">
34
+ <SearchBar
35
+ data={{
36
+ ...props,
37
+ autocomplete: {
38
+ id: hostQuerySearchID,
39
+ url: '/hosts/auto_complete_search',
40
+ useKeyShortcuts: true,
41
+ },
42
+ }}
43
+ onSearch={noop}
44
+ initialQuery={value}
45
+ onBookmarkClick={search => setSearch(search)}
46
+ />
47
+ </div>
48
+ );
49
+ };
50
+
51
+ HostSearch.propTypes = {
52
+ value: PropTypes.string.isRequired,
53
+ setValue: PropTypes.func.isRequired,
54
+ };
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import URI from 'urijs';
4
+ import { SelectVariant } from '@patternfly/react-core';
5
+ import { get } from 'foremanReact/redux/API';
6
+ import { selectResponse, selectIsLoading } from '../../JobWizardSelectors';
7
+ import { SearchSelect } from '../form/SearchSelect';
8
+
9
+ export const useNameSearchAPI = (apiKey, url) => {
10
+ const dispatch = useDispatch();
11
+ const uri = new URI(url);
12
+ const onSearch = search =>
13
+ dispatch(
14
+ get({
15
+ key: apiKey,
16
+ url: uri.addSearch({
17
+ search: `name~"${search}"`,
18
+ }),
19
+ })
20
+ );
21
+
22
+ const response = useSelector(state => selectResponse(state, apiKey));
23
+ const isLoading = useSelector(state => selectIsLoading(state, apiKey));
24
+ return [onSearch, response, isLoading];
25
+ };
26
+
27
+ export const SelectAPI = props => (
28
+ <SearchSelect
29
+ {...props}
30
+ variant={SelectVariant.typeaheadMulti}
31
+ useNameSearch={useNameSearchAPI}
32
+ />
33
+ );