foreman_remote_execution 16.3.1 → 16.4.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +3 -5
  3. data/app/lib/proxy_api/remote_execution_ssh.rb +9 -0
  4. data/app/models/concerns/foreman_remote_execution/host_extensions.rb +12 -4
  5. data/app/models/concerns/foreman_remote_execution/smart_proxy_extensions.rb +14 -0
  6. data/app/views/api/v2/smart_proxies/ca_pubkey.json.rabl +1 -0
  7. data/db/migrate/20250606125543_add_ca_pub_key_to_smart_proxy.rb +5 -0
  8. data/lib/foreman_remote_execution/plugin.rb +1 -0
  9. data/lib/foreman_remote_execution/version.rb +1 -1
  10. data/test/unit/concerns/host_extensions_test.rb +43 -0
  11. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +4 -0
  12. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +216 -129
  13. data/webpack/JobInvocationDetail/TemplateInvocation.js +19 -15
  14. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js +43 -25
  15. data/webpack/JobInvocationDetail/TemplateInvocationPage.js +16 -1
  16. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +114 -72
  17. data/webpack/JobInvocationDetail/index.js +4 -5
  18. data/webpack/JobWizard/JobWizard.js +11 -10
  19. data/webpack/JobWizard/autofill.js +10 -2
  20. data/webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap +1 -1
  21. data/webpack/JobWizard/steps/CategoryAndTemplate/index.js +25 -13
  22. data/webpack/JobWizard/steps/HostsAndInputs/SelectAPI.js +16 -11
  23. data/webpack/JobWizard/steps/form/ResourceSelect.js +18 -16
  24. data/webpack/JobWizard/steps/form/SearchSelect.js +6 -7
  25. data/webpack/JobWizard/validation.js +1 -3
  26. data/webpack/react_app/components/TargetingHosts/index.js +49 -32
  27. metadata +5 -3
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef } from 'react';
1
+ import React, { useEffect, useRef } from 'react';
2
2
  import { isEmpty } from 'lodash';
3
3
  import PropTypes from 'prop-types';
4
4
  import { ClipboardCopyButton, Alert, Skeleton } from '@patternfly/react-core';
@@ -60,6 +60,12 @@ export const TemplateInvocation = ({
60
60
  isExpanded,
61
61
  hostName,
62
62
  hostProxy,
63
+ showOutputType,
64
+ setShowOutputType,
65
+ showTemplatePreview,
66
+ setShowTemplatePreview,
67
+ showCommand,
68
+ setShowCommand,
63
69
  }) => {
64
70
  const intervalRef = useRef(null);
65
71
  const templateURL = showTemplateInvocationUrl(hostID, jobID);
@@ -74,14 +80,6 @@ export const TemplateInvocation = ({
74
80
  responseRef.current = response;
75
81
  }, [response]);
76
82
 
77
- const [showOutputType, setShowOutputType] = useState({
78
- stderr: true,
79
- stdout: true,
80
- debug: true,
81
- });
82
- const [showTemplatePreview, setShowTemplatePreview] = useState(false);
83
- const [showCommand, setShowCommand] = useState(false);
84
-
85
83
  useEffect(() => {
86
84
  const dispatchFetch = () => {
87
85
  dispatch(
@@ -123,12 +121,8 @@ export const TemplateInvocation = ({
123
121
  };
124
122
  }, [isExpanded, dispatch, templateURL, hostID]);
125
123
 
126
- if (!isExpanded) {
127
- return null;
128
- }
129
-
130
- if ((status === STATUS.PENDING && isEmpty(response)) || !response) {
131
- return <Skeleton />;
124
+ if (!response || (status === STATUS.PENDING && isEmpty(response))) {
125
+ return <Skeleton data-testid="template-invocation-skeleton" />;
132
126
  }
133
127
 
134
128
  const errorMessage =
@@ -239,6 +233,16 @@ TemplateInvocation.propTypes = {
239
233
  jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
240
234
  isInTableView: PropTypes.bool,
241
235
  isExpanded: PropTypes.bool,
236
+ showOutputType: PropTypes.shape({
237
+ stderr: PropTypes.bool,
238
+ stdout: PropTypes.bool,
239
+ debug: PropTypes.bool,
240
+ }).isRequired,
241
+ setShowOutputType: PropTypes.func.isRequired,
242
+ showTemplatePreview: PropTypes.bool.isRequired,
243
+ setShowTemplatePreview: PropTypes.func.isRequired,
244
+ showCommand: PropTypes.bool.isRequired,
245
+ setShowCommand: PropTypes.func.isRequired,
242
246
  };
243
247
 
244
248
  TemplateInvocation.defaultProps = {
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import {
4
4
  ToggleGroup,
@@ -27,31 +27,49 @@ export const OutputToggleGroup = ({
27
27
  taskCancellable,
28
28
  permissions,
29
29
  }) => {
30
- const handleSTDERRClick = _isSelected => {
31
- setShowOutputType(prevShowOutputType => ({
32
- ...prevShowOutputType,
33
- stderr: _isSelected,
34
- }));
35
- };
30
+ const handleSTDERRClick = useCallback(
31
+ _isSelected => {
32
+ setShowOutputType(prevShowOutputType => ({
33
+ ...prevShowOutputType,
34
+ stderr: _isSelected,
35
+ }));
36
+ },
37
+ [setShowOutputType]
38
+ );
36
39
 
37
- const handleSTDOUTClick = _isSelected => {
38
- setShowOutputType(prevShowOutputType => ({
39
- ...prevShowOutputType,
40
- stdout: _isSelected,
41
- }));
42
- };
43
- const handleDEBUGClick = _isSelected => {
44
- setShowOutputType(prevShowOutputType => ({
45
- ...prevShowOutputType,
46
- debug: _isSelected,
47
- }));
48
- };
49
- const handlePreviewTemplateClick = _isSelected => {
50
- setShowTemplatePreview(_isSelected);
51
- };
52
- const handleCommandClick = _isSelected => {
53
- setShowCommand(_isSelected);
54
- };
40
+ const handleSTDOUTClick = useCallback(
41
+ _isSelected => {
42
+ setShowOutputType(prevShowOutputType => ({
43
+ ...prevShowOutputType,
44
+ stdout: _isSelected,
45
+ }));
46
+ },
47
+ [setShowOutputType]
48
+ );
49
+
50
+ const handleDEBUGClick = useCallback(
51
+ _isSelected => {
52
+ setShowOutputType(prevShowOutputType => ({
53
+ ...prevShowOutputType,
54
+ debug: _isSelected,
55
+ }));
56
+ },
57
+ [setShowOutputType]
58
+ );
59
+
60
+ const handlePreviewTemplateClick = useCallback(
61
+ _isSelected => {
62
+ setShowTemplatePreview(_isSelected);
63
+ },
64
+ [setShowTemplatePreview]
65
+ );
66
+
67
+ const handleCommandClick = useCallback(
68
+ _isSelected => {
69
+ setShowCommand(_isSelected);
70
+ },
71
+ [setShowCommand]
72
+ );
55
73
 
56
74
  const toggleGroupItems = {
57
75
  stderr: {
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { useSelector } from 'react-redux';
4
4
  import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
@@ -26,6 +26,15 @@ const TemplateInvocationPage = ({
26
26
  ],
27
27
  isPf4: true,
28
28
  };
29
+
30
+ const [showOutputType, setShowOutputType] = useState({
31
+ stderr: true,
32
+ stdout: true,
33
+ debug: true,
34
+ });
35
+ const [showTemplatePreview, setShowTemplatePreview] = useState(false);
36
+ const [showCommand, setShowCommand] = useState(false);
37
+
29
38
  return (
30
39
  <PageLayout
31
40
  header={description}
@@ -39,6 +48,12 @@ const TemplateInvocationPage = ({
39
48
  isExpanded
40
49
  hostName={hostName}
41
50
  hostProxy={hostProxy}
51
+ showOutputType={showOutputType}
52
+ setShowOutputType={setShowOutputType}
53
+ showTemplatePreview={showTemplatePreview}
54
+ setShowTemplatePreview={setShowTemplatePreview}
55
+ showCommand={showCommand}
56
+ setShowCommand={setShowCommand}
42
57
  />
43
58
  </PageLayout>
44
59
  );
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import configureMockStore from 'redux-mock-store';
3
3
  import { Provider } from 'react-redux';
4
- import { render, screen, act, fireEvent } from '@testing-library/react';
4
+ import { render, screen, fireEvent } from '@testing-library/react';
5
5
  import '@testing-library/jest-dom/extend-expect';
6
6
  import * as api from 'foremanReact/redux/API';
7
7
  import * as selectors from '../JobInvocationSelectors';
@@ -10,12 +10,7 @@ import { mockTemplateInvocationResponse } from './fixtures';
10
10
 
11
11
  jest.spyOn(api, 'get');
12
12
  jest.mock('../JobInvocationSelectors');
13
- selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
14
- 'RESOLVED'
15
- );
16
- selectors.selectTemplateInvocation.mockImplementation(() => () =>
17
- mockTemplateInvocationResponse
18
- );
13
+
19
14
  const mockStore = configureMockStore([]);
20
15
  const store = mockStore({
21
16
  HOSTS_API: {
@@ -24,79 +19,122 @@ const store = mockStore({
24
19
  },
25
20
  },
26
21
  });
22
+
23
+ Object.assign(navigator, {
24
+ clipboard: {
25
+ writeText: jest.fn().mockResolvedValue(undefined),
26
+ },
27
+ });
28
+
29
+ const mockProps = {
30
+ hostID: '1',
31
+ jobID: '1',
32
+ isInTableView: false,
33
+ isExpanded: true,
34
+ hostName: 'example-host',
35
+ hostProxy: { name: 'example-proxy', href: '#' },
36
+ showOutputType: { stderr: true, stdout: true, debug: true },
37
+ setShowOutputType: jest.fn(),
38
+ showTemplatePreview: false,
39
+ setShowTemplatePreview: jest.fn(),
40
+ showCommand: false,
41
+ setShowCommand: jest.fn(),
42
+ };
43
+
27
44
  describe('TemplateInvocation', () => {
28
- test('render', async () => {
45
+ beforeEach(() => {
46
+ selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
47
+ 'RESOLVED'
48
+ );
49
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
50
+ mockTemplateInvocationResponse
51
+ );
52
+ });
53
+
54
+ test('render', () => {
29
55
  render(
30
56
  <Provider store={store}>
31
- <TemplateInvocation
32
- hostID="1"
33
- jobID="1"
34
- isInTableView={false}
35
- isExpanded
36
- hostName="example-host"
37
- hostProxy={{ name: 'example-proxy', href: '#' }}
38
- />
57
+ <TemplateInvocation {...mockProps} />
39
58
  </Provider>
40
59
  );
41
60
 
42
61
  expect(screen.getByText('example-host')).toBeInTheDocument();
43
62
  expect(screen.getByText('example-proxy')).toBeInTheDocument();
44
-
45
63
  expect(screen.getByText(/using Smart Proxy/)).toBeInTheDocument();
46
64
  expect(screen.getByText(/Target:/)).toBeInTheDocument();
47
-
48
65
  expect(screen.getByText('This is red text')).toBeInTheDocument();
49
66
  expect(screen.getByText('This is default text')).toBeInTheDocument();
67
+ expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument();
50
68
  });
51
- test('filtering toggles', () => {
52
- render(
69
+
70
+ test('shows "No output" message when all toggles are off', () => {
71
+ const { rerender } = render(
53
72
  <Provider store={store}>
54
- <TemplateInvocation
55
- hostID="1"
56
- jobID="1"
57
- isInTableView={false}
58
- isExpanded
59
- hostName="example-host"
60
- hostProxy={{ name: 'example-proxy', href: '#' }}
61
- />
73
+ <TemplateInvocation {...mockProps} />
62
74
  </Provider>
63
75
  );
64
76
 
65
- act(() => {
66
- fireEvent.click(screen.getByText('STDOUT'));
67
- fireEvent.click(screen.getByText('DEBUG'));
68
- fireEvent.click(screen.getByText('STDERR'));
69
- });
70
77
  expect(
71
- screen.queryAllByText('No output for the selected filters')
72
- ).toHaveLength(1);
73
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(0);
74
- expect(
75
- screen.queryAllByText('StandardError: Job execution failed')
76
- ).toHaveLength(0);
78
+ screen.queryByText('No output for the selected filters')
79
+ ).not.toBeInTheDocument();
80
+
81
+ const newProps = {
82
+ ...mockProps,
83
+ showOutputType: { stderr: false, stdout: false, debug: false },
84
+ };
85
+
86
+ rerender(
87
+ <Provider store={store}>
88
+ <TemplateInvocation {...newProps} />
89
+ </Provider>
90
+ );
77
91
 
78
- act(() => {
79
- fireEvent.click(screen.getByText('STDOUT'));
80
- });
81
92
  expect(
82
- screen.queryAllByText('No output for the selected filters')
83
- ).toHaveLength(0);
84
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
93
+ screen.getByText('No output for the selected filters')
94
+ ).toBeInTheDocument();
95
+ });
96
+
97
+ test('correctly filters specific output types', () => {
98
+ const { rerender } = render(
99
+ <Provider store={store}>
100
+ <TemplateInvocation {...mockProps} />
101
+ </Provider>
102
+ );
103
+
104
+ expect(screen.getByText('Exit status: 1')).toBeInTheDocument(); // stdout
85
105
  expect(
86
- screen.queryAllByText('StandardError: Job execution failed')
87
- ).toHaveLength(0);
106
+ screen.getByText('StandardError: Job execution failed')
107
+ ).toBeInTheDocument(); // debug
88
108
 
89
- act(() => {
90
- fireEvent.click(screen.getByText('DEBUG'));
91
- });
109
+ // Turn off stdout
110
+ rerender(
111
+ <Provider store={store}>
112
+ <TemplateInvocation
113
+ {...mockProps}
114
+ showOutputType={{ stderr: true, stdout: false, debug: true }}
115
+ />
116
+ </Provider>
117
+ );
118
+ expect(screen.queryByText('Exit status: 1')).not.toBeInTheDocument();
92
119
  expect(
93
- screen.queryAllByText('No output for the selected filters')
94
- ).toHaveLength(0);
95
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
120
+ screen.getByText('StandardError: Job execution failed')
121
+ ).toBeInTheDocument();
122
+
123
+ // Turn off debug
124
+ rerender(
125
+ <Provider store={store}>
126
+ <TemplateInvocation
127
+ {...mockProps}
128
+ showOutputType={{ stderr: true, stdout: false, debug: false }}
129
+ />
130
+ </Provider>
131
+ );
132
+ expect(screen.queryByText('Exit status: 1')).not.toBeInTheDocument();
96
133
  expect(
97
- screen.queryAllByText('StandardError: Job execution failed')
98
- ).toHaveLength(1);
134
+ screen.queryByText('StandardError: Job execution failed')
135
+ ).not.toBeInTheDocument();
99
136
  });
137
+
100
138
  test('displays an error alert when there is an error', async () => {
101
139
  selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
102
140
  'ERROR'
@@ -106,14 +144,7 @@ describe('TemplateInvocation', () => {
106
144
  }));
107
145
  render(
108
146
  <Provider store={store}>
109
- <TemplateInvocation
110
- hostID="1"
111
- jobID="1"
112
- isInTableView={false}
113
- isExpanded
114
- hostName="example-host"
115
- hostProxy={{ name: 'example-proxy', href: '#' }}
116
- />
147
+ <TemplateInvocation {...mockProps} />
117
148
  </Provider>
118
149
  );
119
150
 
@@ -129,19 +160,30 @@ describe('TemplateInvocation', () => {
129
160
  selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
130
161
  'PENDING'
131
162
  );
132
- selectors.selectTemplateInvocation.mockImplementation(() => () => ({}));
163
+ selectors.selectTemplateInvocation.mockImplementation(() => () => null);
133
164
  render(
134
165
  <Provider store={store}>
135
- <TemplateInvocation
136
- hostID="1"
137
- jobID="1"
138
- isInTableView={false}
139
- isExpanded
140
- hostName="example-host"
141
- />
166
+ <TemplateInvocation {...mockProps} />
142
167
  </Provider>
143
168
  );
144
169
 
145
- expect(document.querySelectorAll('.pf-v5-c-skeleton')).toHaveLength(1);
170
+ expect(
171
+ screen.getByTestId('template-invocation-skeleton')
172
+ ).toBeInTheDocument();
173
+ });
174
+
175
+ test('copies text to clipboard when clicked', async () => {
176
+ render(
177
+ <Provider store={store}>
178
+ <TemplateInvocation {...mockProps} />
179
+ </Provider>
180
+ );
181
+
182
+ const copyButton = screen.getByLabelText('Copy to clipboard');
183
+ fireEvent.click(copyButton);
184
+ expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
185
+ expect(
186
+ await screen.findByText('Successfully copied to clipboard!')
187
+ ).toBeInTheDocument();
146
188
  });
147
189
  });
@@ -79,15 +79,14 @@ const JobInvocationDetailPage = ({
79
79
  return () => {
80
80
  dispatch(stopInterval(JOB_INVOCATION_KEY));
81
81
  };
82
- // eslint-disable-next-line react-hooks/exhaustive-deps
83
82
  }, [dispatch, id, finished, autoRefresh]);
84
83
 
84
+ const taskId = task?.id;
85
85
  useEffect(() => {
86
- if (task?.id !== undefined) {
87
- dispatch(getTask(`${task?.id}`));
86
+ if (taskId !== undefined) {
87
+ dispatch(getTask(`${taskId}`));
88
88
  }
89
- // eslint-disable-next-line react-hooks/exhaustive-deps
90
- }, [dispatch, task?.id]);
89
+ }, [dispatch, taskId]);
91
90
 
92
91
  const pageStatus =
93
92
  items.id === undefined
@@ -89,9 +89,6 @@ export const JobWizard = ({ rerunData }) => {
89
89
  concurrency_control = {},
90
90
  },
91
91
  }) => {
92
- if (category !== job_category) {
93
- setCategory(job_category);
94
- }
95
92
  const advancedTemplateValues = {};
96
93
  const defaultTemplateValues = {};
97
94
  const inputs = template_inputs;
@@ -131,8 +128,7 @@ export const JobWizard = ({ rerunData }) => {
131
128
  };
132
129
  });
133
130
  },
134
- // eslint-disable-next-line react-hooks/exhaustive-deps
135
- [category.length]
131
+ [setTemplateValues, setAdvancedValues]
136
132
  );
137
133
  useEffect(() => {
138
134
  if (rerunData) {
@@ -153,8 +149,7 @@ export const JobWizard = ({ rerunData }) => {
153
149
  },
154
150
  });
155
151
  }
156
- // eslint-disable-next-line react-hooks/exhaustive-deps
157
- }, [rerunData]);
152
+ }, [rerunData, setDefaults]);
158
153
  useEffect(() => {
159
154
  if (jobTemplateID) {
160
155
  dispatch(
@@ -199,8 +194,14 @@ export const JobWizard = ({ rerunData }) => {
199
194
  })
200
195
  );
201
196
  }
202
- // eslint-disable-next-line react-hooks/exhaustive-deps
203
- }, [rerunData, jobTemplateID, dispatch]);
197
+ }, [
198
+ rerunData,
199
+ jobTemplateID,
200
+ dispatch,
201
+ setDefaults,
202
+ setTemplateValues,
203
+ setAdvancedValues,
204
+ ]);
204
205
 
205
206
  const [isStartsBeforeError, setIsStartsBeforeError] = useState(false);
206
207
  const [isStartsAtError, setIsStartsAtError] = useState(false);
@@ -514,7 +515,7 @@ JobWizard.propTypes = {
514
515
  }),
515
516
  execution_timeout_interval: PropTypes.number,
516
517
  time_to_pickup: PropTypes.number,
517
- remote_execution_feature_id: PropTypes.string,
518
+ remote_execution_feature_id: PropTypes.number,
518
519
  template_invocations: PropTypes.arrayOf(
519
520
  PropTypes.shape({
520
521
  template_id: PropTypes.number,
@@ -95,6 +95,14 @@ export const useAutoFill = ({
95
95
  });
96
96
  }
97
97
  }
98
- // eslint-disable-next-line react-hooks/exhaustive-deps
99
- }, [fills]);
98
+ }, [
99
+ fills,
100
+ setFills,
101
+ setSelectedTargets,
102
+ setHostsSearchQuery,
103
+ setJobTemplateID,
104
+ setTemplateValues,
105
+ setAdvancedValues,
106
+ dispatch,
107
+ ]);
100
108
  };
@@ -57,7 +57,7 @@ Array [
57
57
  "port": null,
58
58
  "preventInvalidHostname": false,
59
59
  "protocol": null,
60
- "query": "resource=ForemanTasks%3A%3ATask&name=some+search",
60
+ "query": "resource=ForemanTasks%3A%3ATask",
61
61
  "urn": null,
62
62
  "username": null,
63
63
  },
@@ -55,8 +55,13 @@ const ConnectedCategoryAndTemplate = ({
55
55
  })
56
56
  );
57
57
  }
58
- // eslint-disable-next-line react-hooks/exhaustive-deps
59
- }, [jobCategoriesStatus]);
58
+ }, [
59
+ jobCategoriesStatus,
60
+ dispatch,
61
+ isCategoryPreselected,
62
+ setCategory,
63
+ setJobTemplate,
64
+ ]);
60
65
 
61
66
  const jobCategories = useSelector(selectJobCategories);
62
67
  const jobTemplatesSearch = useSelector(selectJobTemplatesSearch);
@@ -73,22 +78,29 @@ const ConnectedCategoryAndTemplate = ({
73
78
  per_page: 'all',
74
79
  }),
75
80
  handleSuccess: response => {
76
- if (!jobTemplate)
77
- setJobTemplate(
78
- current =>
79
- current ||
80
- Number(
81
- filterJobTemplates(response?.data?.results)[0]?.id
82
- ) ||
83
- null
84
- );
81
+ const filteredTemplates = filterJobTemplates(
82
+ response?.data?.results
83
+ );
84
+ setJobTemplate(current => {
85
+ // Check if current template is in the new category's template list.
86
+ // This preserves the user's selection when changing categories on rerun,
87
+ // preventing the category from flashing and reverting back (Issue #38899).
88
+ // We check the state value (current) rather than the prop to avoid race conditions.
89
+ if (
90
+ current &&
91
+ filteredTemplates.some(template => template.id === current)
92
+ ) {
93
+ return current;
94
+ }
95
+ // Otherwise, select the first template from the new category
96
+ return Number(filteredTemplates[0]?.id) || null;
97
+ });
85
98
  },
86
99
  })
87
100
  );
88
101
  }
89
102
  }
90
- // eslint-disable-next-line react-hooks/exhaustive-deps
91
- }, [category, dispatch]);
103
+ }, [category, dispatch, jobTemplatesSearch, setJobTemplate]);
92
104
 
93
105
  const jobTemplates = useSelector(selectJobTemplates);
94
106
 
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import URI from 'urijs';
4
4
  import { SelectVariant } from '@patternfly/react-core/deprecated';
@@ -8,16 +8,21 @@ import { SearchSelect } from '../form/SearchSelect';
8
8
 
9
9
  export const useNameSearchAPI = (apiKey, url) => {
10
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
- );
11
+
12
+ const onSearch = useCallback(
13
+ search => {
14
+ const uri = new URI(url);
15
+ dispatch(
16
+ get({
17
+ key: apiKey,
18
+ url: uri.addSearch({
19
+ search: `name~"${search}"`,
20
+ }),
21
+ })
22
+ );
23
+ },
24
+ [dispatch, apiKey, url]
25
+ );
21
26
 
22
27
  const response = useSelector(state => selectResponse(state, apiKey));
23
28
  const isLoading = useSelector(state => selectIsLoading(state, apiKey));
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import {
4
4
  Select,
@@ -25,28 +25,30 @@ export const ResourceSelect = ({
25
25
  const { perPage } = useForemanSettings();
26
26
  const maxResults = perPage;
27
27
  const dispatch = useDispatch();
28
- const uri = new URI(url);
29
- const onSearch = search => {
30
- dispatch(
31
- get({
32
- key: apiKey,
33
- url: uri.addSearch(search),
34
- })
35
- );
36
- };
28
+ const onSearch = useCallback(
29
+ search => {
30
+ const uri = new URI(url);
31
+ dispatch(
32
+ get({
33
+ key: apiKey,
34
+ url: uri.addSearch(search),
35
+ })
36
+ );
37
+ },
38
+ [dispatch, apiKey, url]
39
+ );
37
40
 
38
41
  const response = useSelector(state => selectResponse(state, apiKey));
39
42
  const isLoading = useSelector(state => selectIsLoading(state, apiKey));
40
43
  const [isOpen, setIsOpen] = useState(false);
41
44
  const [typingTimeout, setTypingTimeout] = useState(null);
45
+ const initializedRef = useRef(false);
42
46
  useEffect(() => {
43
- onSearch(selected ? { id: selected } : {});
44
- if (typingTimeout) {
45
- return () => clearTimeout(typingTimeout);
47
+ if (!initializedRef.current) {
48
+ onSearch(selected ? { id: selected } : {});
49
+ initializedRef.current = true;
46
50
  }
47
- return undefined;
48
- // eslint-disable-next-line react-hooks/exhaustive-deps
49
- }, []);
51
+ }, [onSearch, selected]);
50
52
  let selectOptions = [];
51
53
  if (response.subtotal > maxResults) {
52
54
  selectOptions = [