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
@@ -1,44 +1,178 @@
1
- import React, { useState } from 'react';
2
- import { Button, Form } from '@patternfly/react-core';
3
- import { translate as __ } from 'foremanReact/common/I18n';
1
+ import React, { useEffect, useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Form } from '@patternfly/react-core';
4
4
  import { ScheduleType } from './ScheduleType';
5
5
  import { RepeatOn } from './RepeatOn';
6
6
  import { QueryType } from './QueryType';
7
7
  import { StartEndDates } from './StartEndDates';
8
- import { repeatTypes, WIZARD_TITLES } from '../../JobWizardConstants';
8
+ import { WIZARD_TITLES, repeatTypes } from '../../JobWizardConstants';
9
+ import { PurposeField } from './PurposeField';
9
10
  import { WizardTitle } from '../form/WizardTitle';
10
11
 
11
- const Schedule = () => {
12
- const [repeatType, setRepeatType] = useState(repeatTypes.noRepeat);
13
- const [repeatAmount, setRepeatAmount] = useState('');
14
- const [starts, setStarts] = useState('');
15
- const [ends, setEnds] = useState('');
12
+ const Schedule = ({ scheduleValue, setScheduleValue, setValid }) => {
13
+ const {
14
+ repeatType,
15
+ repeatAmount,
16
+ repeatData,
17
+ startsAt,
18
+ startsBefore,
19
+ ends,
20
+ isNeverEnds,
21
+ isFuture,
22
+ isTypeStatic,
23
+ purpose,
24
+ } = scheduleValue;
25
+ const [validEnd, setValidEnd] = useState(true);
26
+ const [repeatValid, setRepeatValid] = useState(true);
16
27
 
28
+ useEffect(() => {
29
+ if (!validEnd || !repeatValid) {
30
+ setValid(false);
31
+ } else if (isFuture && (startsAt.length || startsBefore.length)) {
32
+ setValid(true);
33
+ } else if (!isFuture) {
34
+ setValid(true);
35
+ } else {
36
+ setValid(false);
37
+ }
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, [startsAt, startsBefore, isFuture, validEnd, repeatValid]);
17
40
  return (
18
41
  <>
19
42
  <WizardTitle title={WIZARD_TITLES.schedule} />
20
43
  <Form className="schedule-tab">
21
- <ScheduleType />
44
+ <ScheduleType
45
+ isFuture={isFuture}
46
+ setIsFuture={newValue => {
47
+ if (!newValue) {
48
+ // if schedule type is execute now
49
+ setScheduleValue(current => ({
50
+ ...current,
51
+ startsAt: '',
52
+ startsBefore: '',
53
+ isFuture: newValue,
54
+ }));
55
+ } else {
56
+ setScheduleValue(current => ({
57
+ ...current,
58
+ startsAt: new Date().toISOString(),
59
+ isFuture: newValue,
60
+ }));
61
+ }
62
+ }}
63
+ />
22
64
 
23
65
  <RepeatOn
24
66
  repeatType={repeatType}
25
- setRepeatType={setRepeatType}
67
+ repeatData={repeatData}
68
+ setRepeatType={newValue => {
69
+ setScheduleValue(current => ({
70
+ ...current,
71
+ repeatType: newValue,
72
+ startsBefore: '',
73
+ }));
74
+ }}
75
+ setRepeatData={newValue => {
76
+ setScheduleValue(current => ({
77
+ ...current,
78
+ repeatData: newValue,
79
+ }));
80
+ }}
26
81
  repeatAmount={repeatAmount}
27
- setRepeatAmount={setRepeatAmount}
82
+ setRepeatAmount={newValue => {
83
+ setScheduleValue(current => ({
84
+ ...current,
85
+ repeatAmount: newValue,
86
+ }));
87
+ }}
88
+ setValid={setRepeatValid}
28
89
  />
29
90
  <StartEndDates
30
- starts={starts}
31
- setStarts={setStarts}
91
+ startsAt={startsAt}
92
+ setStartsAt={newValue => {
93
+ if (!isFuture) {
94
+ setScheduleValue(current => ({
95
+ ...current,
96
+ isFuture: true,
97
+ }));
98
+ }
99
+ setScheduleValue(current => ({
100
+ ...current,
101
+ startsAt: newValue,
102
+ }));
103
+ }}
104
+ startsBefore={startsBefore}
105
+ setStartsBefore={newValue => {
106
+ if (!isFuture) {
107
+ setScheduleValue(current => ({
108
+ ...current,
109
+ isFuture: true,
110
+ }));
111
+ }
112
+ setScheduleValue(current => ({
113
+ ...current,
114
+ startsBefore: newValue,
115
+ }));
116
+ }}
32
117
  ends={ends}
33
- setEnds={setEnds}
118
+ setEnds={newValue => {
119
+ setScheduleValue(current => ({
120
+ ...current,
121
+ ends: newValue,
122
+ }));
123
+ }}
124
+ isNeverEnds={isNeverEnds}
125
+ setIsNeverEnds={newValue => {
126
+ setScheduleValue(current => ({
127
+ ...current,
128
+ isNeverEnds: newValue,
129
+ }));
130
+ }}
131
+ validEnd={validEnd}
132
+ setValidEnd={setValidEnd}
133
+ isFuture={isFuture}
134
+ isStartBeforeDisabled={repeatType !== repeatTypes.noRepeat}
135
+ isEndDisabled={repeatType === repeatTypes.noRepeat}
136
+ />
137
+ <QueryType
138
+ isTypeStatic={isTypeStatic}
139
+ setIsTypeStatic={newValue => {
140
+ setScheduleValue(current => ({
141
+ ...current,
142
+ isTypeStatic: newValue,
143
+ }));
144
+ }}
145
+ />
146
+ <PurposeField
147
+ isDisabled={repeatType === repeatTypes.noRepeat}
148
+ purpose={purpose}
149
+ setPurpose={newValue => {
150
+ setScheduleValue(current => ({
151
+ ...current,
152
+ purpose: newValue,
153
+ }));
154
+ }}
34
155
  />
35
- <Button variant="link" className="advanced-scheduling-button" isInline>
36
- {__('Advanced scheduling')}
37
- </Button>
38
- <QueryType />
39
156
  </Form>
40
157
  </>
41
158
  );
42
159
  };
43
160
 
161
+ Schedule.propTypes = {
162
+ scheduleValue: PropTypes.shape({
163
+ repeatType: PropTypes.string.isRequired,
164
+ repeatAmount: PropTypes.string,
165
+ repeatData: PropTypes.object,
166
+ startsAt: PropTypes.string,
167
+ startsBefore: PropTypes.string,
168
+ ends: PropTypes.string,
169
+ isFuture: PropTypes.bool,
170
+ isNeverEnds: PropTypes.bool,
171
+ isTypeStatic: PropTypes.bool,
172
+ purpose: PropTypes.string,
173
+ }).isRequired,
174
+ setScheduleValue: PropTypes.func.isRequired,
175
+ setValid: PropTypes.func.isRequired,
176
+ };
177
+
44
178
  export default Schedule;
@@ -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,
@@ -23,6 +27,7 @@ const TemplateSearchField = ({
23
27
  // eslint-disable-next-line react-hooks/exhaustive-deps
24
28
  }, [searchQuery]);
25
29
  const id = name.replace(/ /g, '-');
30
+ const props = getControllerSearchProps(controller.replace('/', '_'), name);
26
31
  return (
27
32
  <FormGroup
28
33
  label={name}
@@ -34,14 +39,15 @@ const TemplateSearchField = ({
34
39
  <SearchBar
35
40
  initialQuery={defaultValue}
36
41
  data={{
37
- controller,
42
+ ...props,
38
43
  autocomplete: {
39
44
  id: name,
40
- url: `/${controller}/auto_complete_search`,
45
+ url,
41
46
  useKeyShortcuts: true,
42
47
  },
48
+ bookmarks: null,
43
49
  }}
44
- onSearch={() => null}
50
+ onSearch={noop}
45
51
  />
46
52
  </FormGroup>
47
53
  );
@@ -52,15 +58,39 @@ export const formatter = (input, values, setValue) => {
52
58
  const inputType = input.value_type;
53
59
  const isTextType = inputType === 'plain' || !inputType; // null defaults to plain
54
60
 
55
- 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;
56
68
  const labelText = input.description;
57
69
  const value = values[name];
58
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
+ }
59
90
  if (isSelectType) {
60
91
  const options = input.options.split(/\r?\n/).map(option => option.trim());
61
92
  return (
62
93
  <SelectField
63
- aria-label={name}
64
94
  key={id}
65
95
  isRequired={required}
66
96
  label={name}
@@ -116,14 +146,14 @@ export const formatter = (input, values, setValue) => {
116
146
  );
117
147
  }
118
148
  if (inputType === 'search') {
119
- const controller = input.resource_type;
120
- // TODO: get text from redux autocomplete
149
+ const controller = input.resource_type_tableize;
121
150
  return (
122
151
  <TemplateSearchField
123
152
  key={id}
124
153
  name={name}
125
154
  defaultValue={value}
126
- controller={controller}
155
+ controller={resourceType}
156
+ url={`/${controller}/auto_complete_search`}
127
157
  labelText={labelText}
128
158
  required={required}
129
159
  setValue={setValue}
@@ -138,6 +168,7 @@ export const formatter = (input, values, setValue) => {
138
168
  TemplateSearchField.propTypes = {
139
169
  name: PropTypes.string.isRequired,
140
170
  controller: PropTypes.string.isRequired,
171
+ url: PropTypes.string.isRequired,
141
172
  labelText: PropTypes.string,
142
173
  required: PropTypes.bool.isRequired,
143
174
  defaultValue: PropTypes.string,
@@ -2,6 +2,7 @@ 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,7 +10,7 @@ export const NumberInput = ({ formProps, inputProps }) => {
9
10
  return (
10
11
  <FormGroup
11
12
  {...formProps}
12
- helperTextInvalid={__('Has to be a number')}
13
+ helperTextInvalid={__('Has to be a positive number')}
13
14
  validated={validated}
14
15
  >
15
16
  <TextInput
@@ -18,7 +19,7 @@ export const NumberInput = ({ formProps, inputProps }) => {
18
19
  {...inputProps}
19
20
  onChange={newValue => {
20
21
  setValidated(
21
- /^\d+$/.test(newValue) || newValue === ''
22
+ isPositiveNumber(newValue) || newValue === ''
22
23
  ? ValidatedOptions.noval
23
24
  : ValidatedOptions.error
24
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,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
+ });