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
@@ -0,0 +1,52 @@
1
+ import React, { useState } from 'react';
2
+ import { useQuery } from '@apollo/client';
3
+ import { SelectVariant } from '@patternfly/react-core';
4
+ import {
5
+ useForemanOrganization,
6
+ useForemanLocation,
7
+ } from 'foremanReact/Root/Context/ForemanContext';
8
+ import { HOSTS, HOST_GROUPS, dataName } from '../../JobWizardConstants';
9
+ import { SearchSelect } from '../form/SearchSelect';
10
+ import hostsQuery from './hosts.gql';
11
+ import hostgroupsQuery from './hostgroups.gql';
12
+
13
+ export const useNameSearchGQL = apiKey => {
14
+ const org = useForemanOrganization();
15
+ const location = useForemanLocation();
16
+ const [search, setSearch] = useState('');
17
+ const queries = {
18
+ [HOSTS]: hostsQuery,
19
+ [HOST_GROUPS]: hostgroupsQuery,
20
+ };
21
+ const { loading, data } = useQuery(queries[apiKey], {
22
+ variables: {
23
+ search: [
24
+ `name~"${search}"`,
25
+ org ? `organization_id=${org.id}` : null,
26
+ location ? `location_id=${location.id}` : null,
27
+ ]
28
+ .filter(i => i)
29
+ .join(' and '),
30
+ },
31
+ });
32
+ return [
33
+ setSearch,
34
+ {
35
+ subtotal: data?.[dataName[apiKey]]?.totalCount,
36
+ results:
37
+ data?.[dataName[apiKey]]?.nodes.map(node => ({
38
+ id: node.name,
39
+ name: node.name,
40
+ })) || [],
41
+ },
42
+ loading,
43
+ ];
44
+ };
45
+
46
+ export const SelectGQL = props => (
47
+ <SearchSelect
48
+ {...props}
49
+ variant={SelectVariant.typeaheadMulti}
50
+ useNameSearch={useNameSearchGQL}
51
+ />
52
+ );
@@ -1,25 +1,100 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Chip, ChipGroup } from '@patternfly/react-core';
3
+ import { Chip, ChipGroup, Button } from '@patternfly/react-core';
4
+ import { translate as __ } from 'foremanReact/common/I18n';
5
+ import { hostMethods } from '../../JobWizardConstants';
4
6
 
5
- export const SelectedChips = ({ selected, setSelected }) => {
7
+ const SelectedChip = ({ selected, setSelected, categoryName }) => {
6
8
  const deleteItem = itemToRemove => {
7
9
  setSelected(oldSelected =>
8
- oldSelected.filter(item => item !== itemToRemove)
10
+ oldSelected.filter(({ id }) => id !== itemToRemove)
9
11
  );
10
12
  };
11
13
  return (
12
- <ChipGroup className="hosts-chip-group">
13
- {selected.map(chip => (
14
- <Chip key={chip} id={chip} onClick={() => deleteItem(chip)}>
15
- {chip}
14
+ <ChipGroup className="hosts-chip-group" categoryName={categoryName}>
15
+ {selected.map(({ name, id }, index) => (
16
+ <Chip
17
+ key={index}
18
+ id={id}
19
+ onClick={() => deleteItem(id)}
20
+ closeBtnAriaLabel={`Close ${name}`}
21
+ >
22
+ {name}
16
23
  </Chip>
17
24
  ))}
18
25
  </ChipGroup>
19
26
  );
20
27
  };
21
28
 
29
+ export const SelectedChips = ({
30
+ selectedHosts,
31
+ setSelectedHosts,
32
+ selectedHostCollections,
33
+ setSelectedHostCollections,
34
+ selectedHostGroups,
35
+ setSelectedHostGroups,
36
+ hostsSearchQuery,
37
+ clearSearch,
38
+ }) => {
39
+ const clearAll = () => {
40
+ setSelectedHosts(() => []);
41
+ setSelectedHostCollections(() => []);
42
+ setSelectedHostGroups(() => []);
43
+ clearSearch();
44
+ };
45
+ const showClear =
46
+ selectedHosts.length ||
47
+ selectedHostCollections.length ||
48
+ selectedHostGroups.length ||
49
+ hostsSearchQuery;
50
+ return (
51
+ <div className="selected-chips">
52
+ <SelectedChip
53
+ selected={selectedHosts}
54
+ categoryName={hostMethods.hosts}
55
+ setSelected={setSelectedHosts}
56
+ />
57
+ <SelectedChip
58
+ selected={selectedHostCollections}
59
+ categoryName={hostMethods.hostCollections}
60
+ setSelected={setSelectedHostCollections}
61
+ />
62
+ <SelectedChip
63
+ selected={selectedHostGroups}
64
+ categoryName={hostMethods.hostGroups}
65
+ setSelected={setSelectedHostGroups}
66
+ />
67
+ <SelectedChip
68
+ selected={
69
+ hostsSearchQuery
70
+ ? [{ id: hostsSearchQuery, name: hostsSearchQuery }]
71
+ : []
72
+ }
73
+ categoryName={hostMethods.searchQuery}
74
+ setSelected={clearSearch}
75
+ />
76
+ {showClear && (
77
+ <Button variant="link" className="clear-chips" onClick={clearAll}>
78
+ {__('Clear filters')}
79
+ </Button>
80
+ )}
81
+ </div>
82
+ );
83
+ };
84
+
22
85
  SelectedChips.propTypes = {
86
+ selectedHosts: PropTypes.array.isRequired,
87
+ setSelectedHosts: PropTypes.func.isRequired,
88
+ selectedHostCollections: PropTypes.array.isRequired,
89
+ setSelectedHostCollections: PropTypes.func.isRequired,
90
+ selectedHostGroups: PropTypes.array.isRequired,
91
+ setSelectedHostGroups: PropTypes.func.isRequired,
92
+ hostsSearchQuery: PropTypes.string.isRequired,
93
+ clearSearch: PropTypes.func.isRequired,
94
+ };
95
+
96
+ SelectedChip.propTypes = {
97
+ categoryName: PropTypes.string.isRequired,
23
98
  selected: PropTypes.array.isRequired,
24
99
  setSelected: PropTypes.func.isRequired,
25
100
  };
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import { MockedProvider } from '@apollo/client/testing';
5
+ import * as api from 'foremanReact/redux/API';
6
+ import * as routerSelectors from 'foremanReact/routes/RouterSelector';
7
+ import { JobWizard } from '../../../JobWizard';
8
+ import * as selectors from '../../../JobWizardSelectors';
9
+ import { testSetup, mockApi, gqlMock } from '../../../__tests__/fixtures';
10
+
11
+ const store = testSetup(selectors, api);
12
+ mockApi(api);
13
+ const lodash = require('lodash');
14
+
15
+ lodash.debounce = fn => fn;
16
+
17
+ describe('Hosts', () => {
18
+ it('Host selection chips removal and keep state between steps', async () => {
19
+ render(
20
+ <MockedProvider mocks={gqlMock} addTypename={false}>
21
+ <Provider store={store}>
22
+ <JobWizard />
23
+ </Provider>
24
+ </MockedProvider>
25
+ );
26
+ await act(async () => {
27
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
28
+ await new Promise(resolve => setTimeout(resolve, 0)); // to resolve gql
29
+ });
30
+ const select = name =>
31
+ screen.getByRole('button', { name: `${name} toggle` });
32
+ fireEvent.click(select('hosts'));
33
+ await act(async () => {
34
+ fireEvent.click(screen.getByText('host1'));
35
+ fireEvent.click(screen.getByText('host2'));
36
+ });
37
+ fireEvent.click(
38
+ screen.getByText('Hosts', { selector: '.pf-c-select__toggle-text' })
39
+ );
40
+ await act(async () => {
41
+ fireEvent.click(screen.getByText('Host groups'));
42
+ });
43
+ fireEvent.click(select('host groups'));
44
+ await act(async () => {
45
+ fireEvent.click(screen.getByText('host_group1'));
46
+ });
47
+
48
+ fireEvent.click(
49
+ screen.getByText('Host groups', { selector: '.pf-c-select__toggle-text' })
50
+ );
51
+ await act(async () => {
52
+ fireEvent.click(screen.getByText('Host collections'));
53
+ });
54
+ fireEvent.click(select('host collections'));
55
+ await act(async () => {
56
+ fireEvent.click(screen.getByText('host_collection1'));
57
+ });
58
+
59
+ expect(screen.queryAllByText('host1')).toHaveLength(1);
60
+ expect(screen.queryAllByText('host2')).toHaveLength(1);
61
+ expect(screen.queryAllByText('host3')).toHaveLength(0);
62
+ const chip1 = screen.getByRole('button', { name: 'Close host1 host1' });
63
+ await act(async () => {
64
+ fireEvent.click(chip1);
65
+ });
66
+ expect(screen.queryAllByText('host1')).toHaveLength(0);
67
+ expect(screen.queryAllByText('host3')).toHaveLength(0);
68
+ expect(screen.queryAllByText('host2')).toHaveLength(1);
69
+ expect(screen.queryAllByText('host_group1')).toHaveLength(1);
70
+ expect(screen.queryAllByText('host_collection1')).toHaveLength(1);
71
+
72
+ await act(async () => {
73
+ fireEvent.click(screen.getByText('Category and Template'));
74
+ });
75
+ await act(async () => {
76
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
77
+ });
78
+ expect(screen.queryAllByText('host2')).toHaveLength(1);
79
+ expect(screen.queryAllByText('host_group1')).toHaveLength(1);
80
+
81
+ await act(async () => {
82
+ fireEvent.click(screen.getByText('Clear filters'));
83
+ });
84
+
85
+ expect(screen.queryAllByText('host2')).toHaveLength(0);
86
+ expect(screen.queryAllByText('host_group1')).toHaveLength(0);
87
+ });
88
+ it('Host Collection isnt shown without katello', async () => {
89
+ selectors.selectWithKatello.mockImplementation(() => false);
90
+ render(
91
+ <MockedProvider mocks={gqlMock} addTypename={false}>
92
+ <Provider store={store}>
93
+ <JobWizard />
94
+ </Provider>
95
+ </MockedProvider>
96
+ );
97
+ await act(async () => {
98
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
99
+ });
100
+
101
+ expect(screen.queryAllByText('Hosts')).toHaveLength(1);
102
+ await act(async () => {
103
+ fireEvent.click(
104
+ screen.getByText('Hosts', { selector: '.pf-c-select__toggle-text' })
105
+ );
106
+ });
107
+ expect(screen.queryAllByText('Host groups')).toHaveLength(1);
108
+ expect(screen.queryAllByText('Search query')).toHaveLength(1);
109
+ expect(screen.queryAllByText('Host collections')).toHaveLength(0);
110
+ });
111
+ it('Host fill list from url', async () => {
112
+ routerSelectors.selectRouterLocation.mockImplementation(() => ({
113
+ search: '?host_ids%5B%5D=host1&host_ids%5B%5D=host3',
114
+ }));
115
+ render(
116
+ <MockedProvider mocks={gqlMock} addTypename={false}>
117
+ <Provider store={store}>
118
+ <JobWizard />
119
+ </Provider>
120
+ </MockedProvider>
121
+ );
122
+ await act(async () => {
123
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
124
+ });
125
+ api.get.mock.calls.forEach(call => {
126
+ if (call[0].key === 'HOST_IDS') {
127
+ expect(call[0].params).toEqual({ search: 'id = host1 or id = host3' });
128
+ }
129
+ });
130
+
131
+ expect(screen.queryAllByText('host1')).toHaveLength(1);
132
+ expect(screen.queryAllByText('host2')).toHaveLength(0);
133
+ expect(screen.queryAllByText('host3')).toHaveLength(1);
134
+ });
135
+ it('Host fill search from url', async () => {
136
+ routerSelectors.selectRouterLocation.mockImplementation(() => ({
137
+ search: 'search=os=gnome',
138
+ }));
139
+ render(
140
+ <MockedProvider mocks={gqlMock} addTypename={false}>
141
+ <Provider store={store}>
142
+ <JobWizard />
143
+ </Provider>
144
+ </MockedProvider>
145
+ );
146
+ await act(async () => {
147
+ fireEvent.click(screen.getByText('Target hosts and inputs'));
148
+ });
149
+ expect(screen.queryAllByText('os=gnome')).toHaveLength(1);
150
+ });
151
+ });
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  import { Provider } from 'react-redux';
3
3
  import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import { MockedProvider } from '@apollo/client/testing';
4
5
  import * as api from 'foremanReact/redux/API';
5
6
  import { JobWizard } from '../../../JobWizard';
6
7
  import * as selectors from '../../../JobWizardSelectors';
7
- import { testSetup, mockApi } from '../../../__tests__/fixtures';
8
+ import { testSetup, mockApi, gqlMock } from '../../../__tests__/fixtures';
8
9
  import { WIZARD_TITLES } from '../../../JobWizardConstants';
9
10
 
10
11
  const store = testSetup(selectors, api);
@@ -13,9 +14,11 @@ mockApi(api);
13
14
  describe('TemplateInputs', () => {
14
15
  it('should save data between steps for template input fields', async () => {
15
16
  render(
16
- <Provider store={store}>
17
- <JobWizard />
18
- </Provider>
17
+ <MockedProvider mocks={gqlMock} addTypename={false}>
18
+ <Provider store={store}>
19
+ <JobWizard />
20
+ </Provider>
21
+ </MockedProvider>
19
22
  );
20
23
  await act(async () => {
21
24
  fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
@@ -0,0 +1,18 @@
1
+ export const buildHostQuery = (selected, search) => {
2
+ const { hosts, hostCollections, hostGroups } = selected;
3
+ const hostsSearch = `(name ^ (${hosts.map(({ id }) => id).join(',')}))`;
4
+ const hostCollectionsSearch = `(host_collection_id ^ (${hostCollections
5
+ .map(({ id }) => id)
6
+ .join(',')}))`;
7
+ const hostGroupsSearch = `(hostgroup_id ^ (${hostGroups
8
+ .map(({ id }) => id)
9
+ .join(',')}))`;
10
+ return [
11
+ hosts.length ? hostsSearch : false,
12
+ hostCollections.length ? hostCollectionsSearch : false,
13
+ hostGroups.length ? hostGroupsSearch : false,
14
+ search.length ? `(${search})` : false,
15
+ ]
16
+ .filter(Boolean)
17
+ .join(' or ');
18
+ };
@@ -0,0 +1,8 @@
1
+ query($search: String!) {
2
+ hostgroups(first: 100, last: 100, search: $search) {
3
+ totalCount
4
+ nodes {
5
+ name
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ query($search: String!) {
2
+ hosts(first: 100, last: 100, search: $search) {
3
+ totalCount
4
+ nodes {
5
+ name
6
+ }
7
+ }
8
+ }
@@ -1,66 +1,214 @@
1
- import React, { useState } from 'react';
2
- import { Button, Form, FormGroup } from '@patternfly/react-core';
1
+ import React, { useEffect, useState } from 'react';
2
+ import {
3
+ Button,
4
+ Form,
5
+ FormGroup,
6
+ InputGroup,
7
+ Text,
8
+ Spinner,
9
+ } from '@patternfly/react-core';
3
10
  import PropTypes from 'prop-types';
4
- import { useSelector } from 'react-redux';
11
+ import { useSelector, useDispatch } from 'react-redux';
12
+ import { FilterIcon } from '@patternfly/react-icons';
13
+ import { debounce } from 'lodash';
14
+ import { get } from 'foremanReact/redux/API';
5
15
  import { translate as __ } from 'foremanReact/common/I18n';
6
- import { selectTemplateInputs } from '../../JobWizardSelectors';
16
+ import { resetData } from 'foremanReact/components/AutoComplete/AutoCompleteActions';
17
+ import {
18
+ selectTemplateInputs,
19
+ selectWithKatello,
20
+ selectHostCount,
21
+ selectIsLoadingHosts,
22
+ } from '../../JobWizardSelectors';
7
23
  import { SelectField } from '../form/SelectField';
8
24
  import { SelectedChips } from './SelectedChips';
9
25
  import { TemplateInputs } from './TemplateInputs';
10
- import { WIZARD_TITLES } from '../../JobWizardConstants';
26
+ import { HostSearch } from './HostSearch';
27
+ import { HostPreviewModal } from './HostPreviewModal';
28
+ import {
29
+ WIZARD_TITLES,
30
+ HOSTS,
31
+ HOST_COLLECTIONS,
32
+ HOST_GROUPS,
33
+ hostMethods,
34
+ hostsController,
35
+ hostQuerySearchID,
36
+ HOSTS_API,
37
+ HOSTS_TO_PREVIEW_AMOUNT,
38
+ DEBOUNCE_HOST_COUNT,
39
+ } from '../../JobWizardConstants';
11
40
  import { WizardTitle } from '../form/WizardTitle';
41
+ import { SelectAPI } from './SelectAPI';
42
+ import { SelectGQL } from './SelectGQL';
43
+ import { buildHostQuery } from './buildHostQuery';
12
44
 
13
45
  const HostsAndInputs = ({
14
46
  templateValues,
15
47
  setTemplateValues,
16
- selectedHosts,
17
- setSelectedHosts,
48
+ selected,
49
+ setSelected,
50
+ hostsSearchQuery,
51
+ setHostsSearchQuery,
18
52
  }) => {
53
+ const [hostMethod, setHostMethod] = useState(hostMethods.hosts);
54
+ const isLoading = useSelector(selectIsLoadingHosts);
19
55
  const templateInputs = useSelector(selectTemplateInputs);
20
- const hostMethods = [
21
- __('Hosts'),
22
- __('Host collection'),
23
- __('Host group'),
24
- __('Search query'),
25
- ];
26
- const [hostMethod, setHostMethod] = useState(hostMethods[0]);
56
+ const [hostPreviewOpen, setHostPreviewOpen] = useState(false);
57
+ useEffect(() => {
58
+ debounce(() => {
59
+ dispatch(
60
+ get({
61
+ key: HOSTS_API,
62
+ url: '/api/hosts',
63
+ params: {
64
+ search: buildHostQuery(selected, hostsSearchQuery),
65
+ per_page: HOSTS_TO_PREVIEW_AMOUNT,
66
+ },
67
+ })
68
+ );
69
+ }, DEBOUNCE_HOST_COUNT)();
70
+ }, [
71
+ dispatch,
72
+ selected,
73
+ selected.hosts,
74
+ selected.hostCollections,
75
+ selected.hostCollections,
76
+ hostsSearchQuery,
77
+ ]);
78
+ const withKatello = useSelector(selectWithKatello);
79
+ const hostCount = useSelector(selectHostCount);
80
+ const dispatch = useDispatch();
81
+
82
+ const selectedHosts = selected.hosts;
83
+ const setSelectedHosts = newSelected =>
84
+ setSelected(prevSelected => ({
85
+ ...prevSelected,
86
+ hosts: newSelected(prevSelected.hosts),
87
+ }));
88
+ const selectedHostCollections = selected.hostCollections;
89
+ const setSelectedHostCollections = newSelected =>
90
+ setSelected(prevSelected => ({
91
+ ...prevSelected,
92
+ hostCollections: newSelected(prevSelected.hostCollections),
93
+ }));
94
+ const selectedHostGroups = selected.hostGroups;
95
+ const setSelectedHostGroups = newSelected => {
96
+ setSelected(prevSelected => ({
97
+ ...prevSelected,
98
+ hostGroups: newSelected(prevSelected.hostGroups),
99
+ }));
100
+ };
101
+
102
+ const clearSearch = () => {
103
+ dispatch(resetData(hostsController, hostQuerySearchID));
104
+ setHostsSearchQuery('');
105
+ };
27
106
  return (
28
- <>
107
+ <div className="target-hosts-and-inputs">
29
108
  <WizardTitle title={WIZARD_TITLES.hostsAndInputs} />
109
+ {hostPreviewOpen && (
110
+ <HostPreviewModal
111
+ isOpen={hostPreviewOpen}
112
+ setIsOpen={setHostPreviewOpen}
113
+ searchQuery={buildHostQuery(selected, hostsSearchQuery)}
114
+ />
115
+ )}
30
116
  <Form>
31
- <FormGroup fieldId="host_selection">
32
- <SelectField
33
- fieldId="host_methods"
34
- options={hostMethods}
35
- setValue={setHostMethod}
36
- value={hostMethod}
37
- />
38
- <SelectedChips
39
- selected={selectedHosts}
40
- setSelected={setSelectedHosts}
41
- />
117
+ <FormGroup fieldId="host_selection" id="host-selection">
118
+ <InputGroup>
119
+ <SelectField
120
+ isRequired
121
+ className="target-method-select"
122
+ toggleIcon={<FilterIcon />}
123
+ fieldId="host_methods"
124
+ options={Object.values(hostMethods).filter(method => {
125
+ if (method === hostMethods.hostCollections && !withKatello) {
126
+ return false;
127
+ }
128
+ return true;
129
+ })}
130
+ setValue={setHostMethod}
131
+ value={hostMethod}
132
+ />
133
+ {hostMethod === hostMethods.searchQuery && (
134
+ <HostSearch
135
+ setValue={setHostsSearchQuery}
136
+ value={hostsSearchQuery}
137
+ />
138
+ )}
139
+ {hostMethod === hostMethods.hosts && (
140
+ <SelectGQL
141
+ selected={selectedHosts}
142
+ setSelected={setSelectedHosts}
143
+ apiKey={HOSTS}
144
+ name="hosts"
145
+ placeholderText={__('Filter by hosts')}
146
+ />
147
+ )}
148
+ {hostMethod === hostMethods.hostCollections && (
149
+ <SelectAPI
150
+ selected={selectedHostCollections}
151
+ setSelected={setSelectedHostCollections}
152
+ apiKey={HOST_COLLECTIONS}
153
+ name="host collections"
154
+ url="/katello/api/host_collections?per_page=100"
155
+ placeholderText={__('Filter by host collections')}
156
+ />
157
+ )}
158
+ {hostMethod === hostMethods.hostGroups && (
159
+ <SelectGQL
160
+ selected={selectedHostGroups}
161
+ setSelected={setSelectedHostGroups}
162
+ apiKey={HOST_GROUPS}
163
+ name="host groups"
164
+ placeholderText={__('Filter by host groups')}
165
+ />
166
+ )}
167
+ </InputGroup>
42
168
  </FormGroup>
43
- <span>
169
+ <SelectedChips
170
+ selectedHosts={selectedHosts}
171
+ setSelectedHosts={setSelectedHosts}
172
+ selectedHostCollections={selectedHostCollections}
173
+ setSelectedHostCollections={setSelectedHostCollections}
174
+ selectedHostGroups={selectedHostGroups}
175
+ setSelectedHostGroups={setSelectedHostGroups}
176
+ hostsSearchQuery={hostsSearchQuery}
177
+ clearSearch={clearSearch}
178
+ />
179
+ <Text>
44
180
  {__('Apply to')}{' '}
45
- <Button variant="link" isInline>
46
- {selectedHosts.length} {__('hosts')}
47
- </Button>
48
- </span>
181
+ <Button
182
+ variant="link"
183
+ isInline
184
+ onClick={() => setHostPreviewOpen(true)}
185
+ isDisabled={isLoading}
186
+ >
187
+ {hostCount} {__('hosts')}
188
+ </Button>{' '}
189
+ {isLoading && <Spinner size="sm" />}
190
+ </Text>
49
191
  <TemplateInputs
50
192
  inputs={templateInputs}
51
193
  value={templateValues}
52
194
  setValue={setTemplateValues}
53
195
  />
54
196
  </Form>
55
- </>
197
+ </div>
56
198
  );
57
199
  };
58
200
 
59
201
  HostsAndInputs.propTypes = {
60
202
  templateValues: PropTypes.object.isRequired,
61
203
  setTemplateValues: PropTypes.func.isRequired,
62
- selectedHosts: PropTypes.array.isRequired,
63
- setSelectedHosts: PropTypes.func.isRequired,
204
+ selected: PropTypes.shape({
205
+ hosts: PropTypes.array.isRequired,
206
+ hostCollections: PropTypes.array.isRequired,
207
+ hostGroups: PropTypes.array.isRequired,
208
+ }).isRequired,
209
+ setSelected: PropTypes.func.isRequired,
210
+ hostsSearchQuery: PropTypes.string.isRequired,
211
+ setHostsSearchQuery: PropTypes.func.isRequired,
64
212
  };
65
213
 
66
214
  export default HostsAndInputs;