foreman_remote_execution 4.7.0 → 5.1.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +1 -0
  3. data/Gemfile +1 -1
  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_input.rb +13 -0
  8. data/app/graphql/types/recurrence_input.rb +8 -0
  9. data/app/graphql/types/scheduling_input.rb +6 -0
  10. data/app/graphql/types/targeting_enum.rb +7 -0
  11. data/app/lib/actions/remote_execution/run_host_job.rb +8 -1
  12. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  13. data/app/lib/foreman_remote_execution/renderer/scope/input.rb +1 -1
  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 +6 -0
  17. data/app/models/job_invocation_composer.rb +21 -13
  18. data/app/models/job_template.rb +3 -1
  19. data/app/models/remote_execution_provider.rb +18 -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/job_templates/_custom_tabs.html.erb +4 -9
  26. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  27. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  28. data/app/views/template_invocations/show.html.erb +9 -2
  29. data/config/routes.rb +1 -0
  30. data/db/migrate/20210816100932_rex_setting_category_to_dsl.rb +5 -0
  31. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  32. data/db/seeds.d/95-mail_notifications.rb +24 -0
  33. data/foreman_remote_execution.gemspec +1 -1
  34. data/lib/foreman_remote_execution/engine.rb +111 -6
  35. data/lib/foreman_remote_execution/version.rb +1 -1
  36. data/package.json +9 -7
  37. data/test/functional/api/v2/job_invocations_controller_test.rb +20 -0
  38. data/test/functional/cockpit_controller_test.rb +0 -1
  39. data/test/graphql/mutations/job_invocations/create.rb +58 -0
  40. data/test/helpers/remote_execution_helper_test.rb +0 -1
  41. data/test/unit/actions/run_host_job_test.rb +21 -0
  42. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  43. data/test/unit/concerns/host_extensions_test.rb +36 -3
  44. data/test/unit/job_invocation_composer_test.rb +3 -5
  45. data/test/unit/job_invocation_report_template_test.rb +17 -14
  46. data/test/unit/job_template_effective_user_test.rb +0 -4
  47. data/test/unit/remote_execution_provider_test.rb +46 -4
  48. data/test/unit/targeting_test.rb +69 -2
  49. data/webpack/JobWizard/JobWizard.js +142 -28
  50. data/webpack/JobWizard/JobWizard.scss +86 -33
  51. data/webpack/JobWizard/JobWizardConstants.js +44 -0
  52. data/webpack/JobWizard/JobWizardSelectors.js +32 -0
  53. data/webpack/JobWizard/__tests__/fixtures.js +89 -6
  54. data/webpack/JobWizard/__tests__/integration.test.js +29 -22
  55. data/webpack/JobWizard/__tests__/validation.test.js +141 -0
  56. data/webpack/JobWizard/autofill.js +38 -0
  57. data/webpack/JobWizard/index.js +7 -0
  58. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +23 -9
  59. data/webpack/JobWizard/steps/AdvancedFields/DescriptionField.js +32 -9
  60. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +48 -1
  61. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +242 -23
  62. data/webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap +82 -0
  63. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +5 -2
  64. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +3 -2
  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 +100 -0
  71. data/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js +23 -0
  72. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/HostsAndInputs.test.js +151 -0
  73. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +53 -0
  74. data/webpack/JobWizard/steps/HostsAndInputs/buildHostQuery.js +18 -0
  75. data/webpack/JobWizard/steps/HostsAndInputs/hostgroups.gql +8 -0
  76. data/webpack/JobWizard/steps/HostsAndInputs/hosts.gql +8 -0
  77. data/webpack/JobWizard/steps/HostsAndInputs/index.js +214 -0
  78. data/webpack/JobWizard/steps/ReviewDetails/index.js +193 -0
  79. data/webpack/JobWizard/steps/Schedule/PurposeField.js +31 -0
  80. data/webpack/JobWizard/steps/Schedule/QueryType.js +46 -43
  81. data/webpack/JobWizard/steps/Schedule/RepeatCron.js +53 -0
  82. data/webpack/JobWizard/steps/Schedule/RepeatDaily.js +37 -0
  83. data/webpack/JobWizard/steps/Schedule/RepeatHour.js +54 -0
  84. data/webpack/JobWizard/steps/Schedule/RepeatMonth.js +46 -0
  85. data/webpack/JobWizard/steps/Schedule/RepeatOn.js +95 -31
  86. data/webpack/JobWizard/steps/Schedule/RepeatWeek.js +70 -0
  87. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  88. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +78 -23
  89. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +402 -0
  90. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +20 -10
  91. data/webpack/JobWizard/steps/Schedule/index.js +166 -29
  92. data/webpack/JobWizard/steps/form/DateTimePicker.js +126 -0
  93. data/webpack/JobWizard/steps/form/FormHelpers.js +4 -0
  94. data/webpack/JobWizard/steps/form/Formatter.js +49 -17
  95. data/webpack/JobWizard/steps/form/NumberInput.js +5 -2
  96. data/webpack/JobWizard/steps/form/ResourceSelect.js +29 -0
  97. data/webpack/JobWizard/steps/form/SearchSelect.js +121 -0
  98. data/webpack/JobWizard/steps/form/SelectField.js +14 -3
  99. data/webpack/JobWizard/steps/form/WizardTitle.js +14 -0
  100. data/webpack/JobWizard/steps/form/__tests__/SelectSearch.test.js +33 -0
  101. data/webpack/JobWizard/submit.js +120 -0
  102. data/webpack/JobWizard/validation.js +53 -0
  103. data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js +2 -0
  104. data/webpack/__mocks__/foremanReact/common/I18n.js +2 -0
  105. data/webpack/__mocks__/foremanReact/components/AutoComplete/AutoCompleteActions.js +1 -0
  106. data/webpack/__mocks__/foremanReact/components/AutoComplete/AutoCompleteConstants.js +1 -0
  107. data/webpack/__mocks__/foremanReact/routes/RouterSelector.js +1 -0
  108. data/webpack/helpers.js +1 -0
  109. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +2 -1
  110. metadata +53 -7
  111. data/app/models/setting/remote_execution.rb +0 -88
  112. data/webpack/JobWizard/steps/AdvancedFields/__tests__/DescriptionField.test.js +0 -23
  113. data/webpack/JobWizard/steps/form/__tests__/Formatter.test.js.example +0 -76
@@ -0,0 +1,126 @@
1
+ import React, { useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ DatePicker,
5
+ TimePicker,
6
+ ValidatedOptions,
7
+ } from '@patternfly/react-core';
8
+ import { debounce } from 'lodash';
9
+ import { translate as __, documentLocale } from 'foremanReact/common/I18n';
10
+
11
+ export const DateTimePicker = ({
12
+ dateTime,
13
+ setDateTime,
14
+ isDisabled,
15
+ ariaLabel,
16
+ allowEmpty,
17
+ }) => {
18
+ const [validated, setValidated] = useState();
19
+ const dateFormat = date =>
20
+ `${date.getFullYear()}/${(date.getMonth() + 1)
21
+ .toString()
22
+ .padStart(2, '0')}/${date
23
+ .getDate()
24
+ .toString()
25
+ .padStart(2, '0')}`;
26
+
27
+ const dateObject = dateTime ? new Date(dateTime) : new Date();
28
+ const formattedDate = dateTime ? dateFormat(dateObject) : '';
29
+ const dateParse = date =>
30
+ new Date(`${date} ${dateObject.getHours()}:${dateObject.getMinutes()}`);
31
+
32
+ const isValidDate = date => date && !Number.isNaN(date.getTime());
33
+
34
+ const isValidTime = time => {
35
+ if (!time) return false;
36
+ const split = time.split(':');
37
+ if (!(split[0].length === 2 && split[1].length === 2)) return false;
38
+ if (isValidDate(new Date(`${formattedDate} ${time}`))) return true;
39
+ if (!formattedDate.length && isValidDate(new Date(`01/01/2020 ${time}`))) {
40
+ const today = new Date();
41
+ today.setHours(split[0]);
42
+ today.setMinutes(split[1]);
43
+ setDateTime(today.toString());
44
+ }
45
+ return false;
46
+ };
47
+
48
+ const onDateChange = newDate => {
49
+ const parsedNewDate = new Date(newDate);
50
+ if (!newDate.length && allowEmpty) {
51
+ setDateTime('');
52
+ setValidated(ValidatedOptions.noval);
53
+ } else if (isValidDate(parsedNewDate)) {
54
+ parsedNewDate.setHours(dateObject.getHours());
55
+ parsedNewDate.setMinutes(dateObject.getMinutes());
56
+ setDateTime(parsedNewDate.toString());
57
+ setValidated(ValidatedOptions.noval);
58
+ } else {
59
+ setValidated(ValidatedOptions.error);
60
+ }
61
+ };
62
+
63
+ const onTimeChange = newTime => {
64
+ if (!newTime.length && allowEmpty) {
65
+ const parsedNewTime = new Date(`${formattedDate} 00:00`);
66
+ setDateTime(parsedNewTime.toString());
67
+ setValidated(ValidatedOptions.noval);
68
+ } else if (isValidTime(newTime)) {
69
+ const parsedNewTime = new Date(`${formattedDate} ${newTime}`);
70
+ setDateTime(parsedNewTime.toString());
71
+ setValidated(ValidatedOptions.noval);
72
+ } else {
73
+ setValidated(ValidatedOptions.error);
74
+ }
75
+ };
76
+ return (
77
+ <>
78
+ <DatePicker
79
+ aria-label={`${ariaLabel} datepicker`}
80
+ value={formattedDate}
81
+ placeholder="yyyy/mm/dd"
82
+ onChange={debounce(onDateChange, 1000, {
83
+ leading: false,
84
+ trailing: true,
85
+ })}
86
+ dateFormat={dateFormat}
87
+ dateParse={dateParse}
88
+ isDisabled={isDisabled}
89
+ locale={documentLocale()}
90
+ invalidFormatText={
91
+ validated === ValidatedOptions.error ? __('Invalid date') : ''
92
+ }
93
+ inputProps={{ validated }}
94
+ />
95
+ <TimePicker
96
+ aria-label={`${ariaLabel} timepicker`}
97
+ className="time-picker"
98
+ time={dateTime ? dateObject.toString() : ''}
99
+ inputProps={dateTime ? {} : { value: '' }}
100
+ placeholder="hh:mm"
101
+ onChange={debounce(onTimeChange, 1000, {
102
+ leading: false,
103
+ trailing: true,
104
+ })}
105
+ is24Hour
106
+ isDisabled={isDisabled}
107
+ invalidFormatErrorMessage={__('Invalid time format')}
108
+ menuAppendTo={() => document.body}
109
+ />
110
+ </>
111
+ );
112
+ };
113
+
114
+ DateTimePicker.propTypes = {
115
+ dateTime: PropTypes.string,
116
+ setDateTime: PropTypes.func.isRequired,
117
+ isDisabled: PropTypes.bool,
118
+ ariaLabel: PropTypes.string,
119
+ allowEmpty: PropTypes.bool,
120
+ };
121
+ DateTimePicker.defaultProps = {
122
+ dateTime: null,
123
+ isDisabled: false,
124
+ ariaLabel: '',
125
+ allowEmpty: true,
126
+ };
@@ -18,3 +18,7 @@ export const helpLabel = (text, id) => {
18
18
  </Popover>
19
19
  );
20
20
  };
21
+
22
+ export const isPositiveNumber = text => parseInt(text, 10) > 0;
23
+
24
+ export const isValidDate = d => d instanceof Date && !Number.isNaN(d);
@@ -3,12 +3,16 @@ import { useSelector } from 'react-redux';
3
3
  import { FormGroup, TextInput, TextArea } from '@patternfly/react-core';
4
4
  import PropTypes from 'prop-types';
5
5
  import SearchBar from 'foremanReact/components/SearchBar';
6
+ import { getControllerSearchProps } from 'foremanReact/constants';
6
7
  import { helpLabel } from './FormHelpers';
7
8
  import { SelectField } from './SelectField';
9
+ import { ResourceSelectAPI } from './ResourceSelect';
10
+ import { noop } from '../../../helpers';
8
11
 
9
12
  const TemplateSearchField = ({
10
13
  name,
11
14
  controller,
15
+ url,
12
16
  labelText,
13
17
  required,
14
18
  defaultValue,
@@ -22,25 +26,28 @@ const TemplateSearchField = ({
22
26
  setValue({ ...values, [name]: searchQuery });
23
27
  // eslint-disable-next-line react-hooks/exhaustive-deps
24
28
  }, [searchQuery]);
29
+ const id = name.replace(/ /g, '-');
30
+ const props = getControllerSearchProps(controller.replace('/', '_'), name);
25
31
  return (
26
32
  <FormGroup
27
33
  label={name}
28
34
  labelIcon={helpLabel(labelText, name)}
29
- fieldId={name}
35
+ fieldId={id}
30
36
  isRequired={required}
31
37
  className="foreman-search-field"
32
38
  >
33
39
  <SearchBar
34
40
  initialQuery={defaultValue}
35
41
  data={{
36
- controller,
42
+ ...props,
37
43
  autocomplete: {
38
44
  id: name,
39
- url: `/${controller}/auto_complete_search`,
45
+ url,
40
46
  useKeyShortcuts: true,
41
47
  },
48
+ bookmarks: null,
42
49
  }}
43
- onSearch={() => null}
50
+ onSearch={noop}
44
51
  />
45
52
  </FormGroup>
46
53
  );
@@ -51,19 +58,43 @@ export const formatter = (input, values, setValue) => {
51
58
  const inputType = input.value_type;
52
59
  const isTextType = inputType === 'plain' || !inputType; // null defaults to plain
53
60
 
54
- const { name, required, hidden_value: hidden } = input;
61
+ const {
62
+ name,
63
+ required,
64
+ hidden_value: hidden,
65
+ resource_type: resourceType,
66
+ value_type: valueType,
67
+ } = input;
55
68
  const labelText = input.description;
56
69
  const value = values[name];
57
-
70
+ const id = name.replace(/ /g, '-');
71
+ if (valueType === 'resource') {
72
+ return (
73
+ <FormGroup
74
+ label={name}
75
+ fieldId={id}
76
+ labelIcon={helpLabel(labelText, name)}
77
+ isRequired={required}
78
+ key={id}
79
+ >
80
+ <ResourceSelectAPI
81
+ name={name}
82
+ apiKey={resourceType.replace('::', '')}
83
+ url={`/ui_job_wizard/resources?resource=${resourceType}`}
84
+ selected={value || {}}
85
+ setSelected={newValue => setValue({ ...values, [name]: newValue })}
86
+ />
87
+ </FormGroup>
88
+ );
89
+ }
58
90
  if (isSelectType) {
59
91
  const options = input.options.split(/\r?\n/).map(option => option.trim());
60
92
  return (
61
93
  <SelectField
62
- aria-label={name}
63
- key={name}
94
+ key={id}
64
95
  isRequired={required}
65
96
  label={name}
66
- fieldId={name}
97
+ fieldId={id}
67
98
  options={options}
68
99
  labelIcon={helpLabel(labelText, name)}
69
100
  value={value}
@@ -77,7 +108,7 @@ export const formatter = (input, values, setValue) => {
77
108
  key={name}
78
109
  label={name}
79
110
  labelIcon={helpLabel(labelText, name)}
80
- fieldId={name}
111
+ fieldId={id}
81
112
  isRequired={required}
82
113
  >
83
114
  <TextArea
@@ -85,7 +116,7 @@ export const formatter = (input, values, setValue) => {
85
116
  className={hidden ? 'masked-input' : null}
86
117
  required={required}
87
118
  rows={2}
88
- id={name}
119
+ id={id}
89
120
  value={value}
90
121
  onChange={newValue => setValue({ ...values, [name]: newValue })}
91
122
  />
@@ -98,7 +129,7 @@ export const formatter = (input, values, setValue) => {
98
129
  key={name}
99
130
  label={name}
100
131
  labelIcon={helpLabel(labelText, name)}
101
- fieldId={name}
132
+ fieldId={id}
102
133
  isRequired={required}
103
134
  >
104
135
  <TextInput
@@ -106,7 +137,7 @@ export const formatter = (input, values, setValue) => {
106
137
  placeholder="YYYY-mm-dd HH:MM"
107
138
  className={hidden ? 'masked-input' : null}
108
139
  required={required}
109
- id={name}
140
+ id={id}
110
141
  type="text"
111
142
  value={value}
112
143
  onChange={newValue => setValue({ ...values, [name]: newValue })}
@@ -115,14 +146,14 @@ export const formatter = (input, values, setValue) => {
115
146
  );
116
147
  }
117
148
  if (inputType === 'search') {
118
- const controller = input.resource_type;
119
- // TODO: get text from redux autocomplete
149
+ const controller = input.resource_type_tableize;
120
150
  return (
121
151
  <TemplateSearchField
122
- key={name}
152
+ key={id}
123
153
  name={name}
124
154
  defaultValue={value}
125
- controller={controller}
155
+ controller={resourceType}
156
+ url={`/${controller}/auto_complete_search`}
126
157
  labelText={labelText}
127
158
  required={required}
128
159
  setValue={setValue}
@@ -137,6 +168,7 @@ export const formatter = (input, values, setValue) => {
137
168
  TemplateSearchField.propTypes = {
138
169
  name: PropTypes.string.isRequired,
139
170
  controller: PropTypes.string.isRequired,
171
+ url: PropTypes.string.isRequired,
140
172
  labelText: PropTypes.string,
141
173
  required: PropTypes.bool.isRequired,
142
174
  defaultValue: PropTypes.string,
@@ -2,21 +2,24 @@ import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { FormGroup, TextInput, ValidatedOptions } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
+ import { isPositiveNumber } from './FormHelpers';
5
6
 
6
7
  export const NumberInput = ({ formProps, inputProps }) => {
7
8
  const [validated, setValidated] = useState();
9
+ const name = inputProps.id.replace(/-/g, ' ');
8
10
  return (
9
11
  <FormGroup
10
12
  {...formProps}
11
- helperTextInvalid={__('Has to be a number')}
13
+ helperTextInvalid={__('Has to be a positive number')}
12
14
  validated={validated}
13
15
  >
14
16
  <TextInput
17
+ aria-label={name}
15
18
  type="text"
16
19
  {...inputProps}
17
20
  onChange={newValue => {
18
21
  setValidated(
19
- /^\d+$/.test(newValue) || newValue === ''
22
+ isPositiveNumber(newValue) || newValue === ''
20
23
  ? ValidatedOptions.noval
21
24
  : ValidatedOptions.error
22
25
  );
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import { useSelector, useDispatch } from 'react-redux';
3
+ import URI from 'urijs';
4
+ import { get } from 'foremanReact/redux/API';
5
+ import { selectResponse, selectIsLoading } from '../../JobWizardSelectors';
6
+ import { SearchSelect } from '../form/SearchSelect';
7
+
8
+ export const useNameSearchAPI = (apiKey, url) => {
9
+ const dispatch = useDispatch();
10
+ const uri = new URI(url);
11
+ const onSearch = search => {
12
+ dispatch(
13
+ get({
14
+ key: apiKey,
15
+ url: uri.addSearch({
16
+ name: search,
17
+ }),
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 ResourceSelectAPI = props => (
28
+ <SearchSelect {...props} useNameSearch={useNameSearchAPI} />
29
+ );
@@ -0,0 +1,121 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
4
+ import Immutable from 'seamless-immutable';
5
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
6
+
7
+ export const maxResults = 100;
8
+
9
+ export const SearchSelect = ({
10
+ name,
11
+ selected,
12
+ setSelected,
13
+ placeholderText,
14
+ useNameSearch,
15
+ apiKey,
16
+ url,
17
+ variant,
18
+ }) => {
19
+ const [onSearch, response, isLoading] = useNameSearch(apiKey, url);
20
+ const [isOpen, setIsOpen] = useState(false);
21
+ const [typingTimeout, setTypingTimeout] = useState(null);
22
+ const autoSearchDelay = 500;
23
+ useEffect(() => {
24
+ onSearch(selected.name || '');
25
+ if (typingTimeout) {
26
+ return () => clearTimeout(typingTimeout);
27
+ }
28
+ return undefined;
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ }, []);
31
+ let selectOptions = [];
32
+ if (response.subtotal > maxResults) {
33
+ selectOptions = [
34
+ <SelectOption
35
+ isDisabled
36
+ key={0}
37
+ description={__('Please refine your search.')}
38
+ >
39
+ {sprintf(
40
+ __('You have %s results to display. Showing first %s results'),
41
+ response.subtotal,
42
+ maxResults
43
+ )}
44
+ </SelectOption>,
45
+ ];
46
+ }
47
+ selectOptions = [
48
+ ...selectOptions,
49
+ ...Immutable.asMutable(response?.results || [])?.map((result, index) => (
50
+ <SelectOption key={index + 1} value={result.id}>
51
+ {result.name}
52
+ </SelectOption>
53
+ )),
54
+ ];
55
+
56
+ const onSelect = (event, selection) => {
57
+ if (variant === SelectVariant.typeahead) {
58
+ setSelected(response.results.find(r => r.id === selection)); // saving the name and id so we will have access to the name between steps
59
+ } else if (variant === SelectVariant.typeaheadMulti) {
60
+ if (selected.map(({ id }) => id).includes(selection)) {
61
+ setSelected(currentSelected =>
62
+ currentSelected.filter(({ id }) => id !== selection)
63
+ );
64
+ } else {
65
+ setSelected(currentSelected => [
66
+ ...currentSelected,
67
+ response.results.find(r => r.id === selection),
68
+ ]);
69
+ }
70
+ }
71
+ };
72
+ const autoSearch = searchTerm => {
73
+ if (typingTimeout) clearTimeout(typingTimeout);
74
+ setTypingTimeout(setTimeout(() => onSearch(searchTerm), autoSearchDelay));
75
+ };
76
+ return (
77
+ <Select
78
+ toggleAriaLabel={`${name} toggle`}
79
+ chipGroupComponent={<></>}
80
+ variant={variant}
81
+ selections={
82
+ variant === SelectVariant.typeahead
83
+ ? selected.id
84
+ : selected.map(({ id }) => id)
85
+ }
86
+ loadingVariant={isLoading ? 'spinner' : null}
87
+ onSelect={onSelect}
88
+ onToggle={setIsOpen}
89
+ isOpen={isOpen}
90
+ className="without_select2"
91
+ maxHeight="45vh"
92
+ onTypeaheadInputChanged={value => {
93
+ autoSearch(value || '');
94
+ }}
95
+ placeholderText={placeholderText}
96
+ onFilter={() => null} // https://github.com/patternfly/patternfly-react/issues/6321
97
+ typeAheadAriaLabel={`${name} typeahead input`}
98
+ >
99
+ {selectOptions}
100
+ </Select>
101
+ );
102
+ };
103
+
104
+ SearchSelect.propTypes = {
105
+ name: PropTypes.string,
106
+ selected: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
107
+ setSelected: PropTypes.func.isRequired,
108
+ placeholderText: PropTypes.string,
109
+ apiKey: PropTypes.string.isRequired,
110
+ url: PropTypes.string,
111
+ useNameSearch: PropTypes.func.isRequired,
112
+ variant: PropTypes.string,
113
+ };
114
+
115
+ SearchSelect.defaultProps = {
116
+ name: 'typeahead select',
117
+ selected: {},
118
+ placeholderText: '',
119
+ url: '',
120
+ variant: SelectVariant.typeahead,
121
+ };
@@ -17,6 +17,16 @@ export const SelectField = ({
17
17
  setIsOpen(false);
18
18
  };
19
19
  const [isOpen, setIsOpen] = useState(false);
20
+ const selectOptions = [
21
+ !isRequired && (
22
+ <SelectOption key={0} value="">
23
+ <p> </p>
24
+ </SelectOption>
25
+ ),
26
+ ...options.map((option, index) => (
27
+ <SelectOption key={index + 1} value={option} />
28
+ )),
29
+ ];
20
30
  return (
21
31
  <FormGroup
22
32
  label={label}
@@ -32,11 +42,12 @@ export const SelectField = ({
32
42
  className="without_select2"
33
43
  maxHeight="45vh"
34
44
  menuAppendTo={() => document.body}
45
+ placeholderText=" " // To prevent showing first option as selected
46
+ aria-labelledby={fieldId}
47
+ toggleAriaLabel={`${label} toggle`}
35
48
  {...props}
36
49
  >
37
- {options.map((option, index) => (
38
- <SelectOption key={index} value={option} />
39
- ))}
50
+ {selectOptions.filter(option => option)}
40
51
  </Select>
41
52
  </FormGroup>
42
53
  );
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Title } from '@patternfly/react-core';
4
+
5
+ export const WizardTitle = ({ title, ...props }) => (
6
+ <Title headingLevel="h2" className="wizard-title" {...props}>
7
+ {title}
8
+ </Title>
9
+ );
10
+
11
+ WizardTitle.propTypes = {
12
+ title: PropTypes.string.isRequired,
13
+ };
14
+ export default WizardTitle;
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { render, fireEvent, screen } from '@testing-library/react';
3
+ import { SearchSelect } from '../SearchSelect';
4
+
5
+ const apiKey = 'HOSTS_KEY';
6
+
7
+ describe('SearchSelect', () => {
8
+ it('too many', () => {
9
+ const onSearch = jest.fn();
10
+ render(
11
+ <SearchSelect
12
+ selected={['hosts1,host2']}
13
+ setSelected={jest.fn()}
14
+ apiKey={apiKey}
15
+ url="/hosts"
16
+ placeholderText="Test hosts"
17
+ useNameSearch={() => [
18
+ onSearch,
19
+ { results: ['host1', 'host2', 'host3'], subtotal: 101 },
20
+ false,
21
+ ]}
22
+ />
23
+ );
24
+ const openSelectbutton = screen.getByRole('button', {
25
+ name: 'typeahead select toggle',
26
+ });
27
+ fireEvent.click(openSelectbutton);
28
+ const tooMany = screen.queryAllByText(
29
+ 'You have %s results to display. Showing first %s results + 101 + 100'
30
+ );
31
+ expect(tooMany).toHaveLength(1);
32
+ });
33
+ });
@@ -0,0 +1,120 @@
1
+ import { post } from 'foremanReact/redux/API';
2
+ import { repeatTypes, JOB_INVOCATION } from './JobWizardConstants';
3
+ import { buildHostQuery } from './steps/HostsAndInputs/buildHostQuery';
4
+
5
+ export const submit = ({
6
+ jobTemplateID,
7
+ templateValues,
8
+ advancedValues,
9
+ scheduleValue,
10
+ selectedTargets,
11
+ hostsSearchQuery,
12
+ location,
13
+ organization,
14
+ dispatch,
15
+ }) => {
16
+ const {
17
+ repeatAmount,
18
+ repeatType,
19
+ repeatData,
20
+ startsAt,
21
+ startsBefore,
22
+ ends,
23
+ purpose,
24
+ } = scheduleValue;
25
+ const {
26
+ effectiveUserValue,
27
+ effectiveUserPassword,
28
+ description,
29
+ timeoutToKill,
30
+ isRandomizedOrdering,
31
+ timeSpan,
32
+ concurrencyLevel,
33
+ templateValues: advancedTemplateValues,
34
+ password,
35
+ keyPassphrase,
36
+ } = advancedValues;
37
+ const getCronLine = () => {
38
+ const [hour, minute] = repeatData.at
39
+ ? repeatData.at.split(':')
40
+ : [null, null];
41
+ switch (repeatType) {
42
+ case repeatTypes.cronline:
43
+ return repeatData.cronline;
44
+ case repeatTypes.monthly:
45
+ return `${minute} ${hour} ${repeatData.days} * *`;
46
+ case repeatTypes.weekly:
47
+ return `${minute} ${hour} * * ${repeatData.daysOfWeek}`;
48
+ case repeatTypes.daily:
49
+ return `${minute} ${hour} * * *`;
50
+ case repeatTypes.hourly:
51
+ return `${repeatData.minute} * * * *`;
52
+ case repeatTypes.noRepeat:
53
+ default:
54
+ return null;
55
+ }
56
+ };
57
+ const api = {
58
+ location,
59
+ organization,
60
+ job_invocation: {
61
+ job_template_id: jobTemplateID,
62
+ targeting_type: scheduleValue?.isTypeStatic
63
+ ? 'static_query'
64
+ : 'dynamic_query',
65
+ randomized_ordering: isRandomizedOrdering,
66
+ inputs: { ...templateValues, ...advancedTemplateValues },
67
+ ssh: {
68
+ effective_user: effectiveUserValue,
69
+ effective_user_password: effectiveUserPassword,
70
+ },
71
+ password,
72
+ key_passphrase: keyPassphrase,
73
+ recurrence:
74
+ repeatType !== repeatTypes.noRepeat
75
+ ? {
76
+ cron_line: getCronLine(),
77
+ max_iteration: repeatAmount,
78
+ end_time: ends.length ? new Date(ends).toISOString() : null,
79
+ purpose,
80
+ }
81
+ : null,
82
+ scheduling:
83
+ startsAt?.length || startsBefore?.length
84
+ ? {
85
+ start_at: startsAt?.length
86
+ ? new Date(startsAt).toISOString()
87
+ : null,
88
+ start_before: startsBefore?.length
89
+ ? new Date(startsBefore).toISOString()
90
+ : null,
91
+ }
92
+ : null,
93
+ concurrency_control: {
94
+ time_span: timeSpan,
95
+ concurrency_level: concurrencyLevel,
96
+ },
97
+ bookmark_id: null,
98
+ search_query:
99
+ buildHostQuery(selectedTargets, hostsSearchQuery) || 'name ~ *',
100
+ description_format: description,
101
+ execution_timeout_interval: timeoutToKill,
102
+ feature: '', // TODO add value after https://github.com/theforeman/foreman_remote_execution/pull/629
103
+ },
104
+ };
105
+
106
+ dispatch(
107
+ post({
108
+ key: JOB_INVOCATION,
109
+ url: '/api/job_invocations',
110
+ params: api,
111
+ handleSuccess: ({ data: { id } }) => {
112
+ window.location.href = `/job_invocations/${id}`;
113
+ },
114
+ errorToast: ({ response }) =>
115
+ response?.date?.error?.message ||
116
+ response?.message ||
117
+ response?.statusText,
118
+ })
119
+ );
120
+ };