foreman_remote_execution 4.5.5 → 4.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) 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 +7 -1
  5. data/app/graphql/types/job_invocation.rb +16 -0
  6. data/app/lib/actions/remote_execution/run_host_job.rb +2 -1
  7. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  8. data/app/mailers/rex_job_mailer.rb +15 -0
  9. data/app/models/job_invocation.rb +4 -0
  10. data/app/models/job_invocation_composer.rb +21 -13
  11. data/app/models/job_template.rb +1 -1
  12. data/app/models/remote_execution_provider.rb +17 -2
  13. data/app/models/rex_mail_notification.rb +13 -0
  14. data/app/models/setting/remote_execution.rb +7 -1
  15. data/app/services/ui_notifications/remote_execution_jobs/base_job_finish.rb +2 -1
  16. data/app/views/dashboard/_latest-jobs.html.erb +21 -0
  17. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  18. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  19. data/app/views/template_invocations/show.html.erb +2 -1
  20. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  21. data/db/seeds.d/95-mail_notifications.rb +24 -0
  22. data/foreman_remote_execution.gemspec +2 -4
  23. data/lib/foreman_remote_execution/engine.rb +4 -0
  24. data/lib/foreman_remote_execution/version.rb +1 -1
  25. data/package.json +6 -6
  26. data/test/functional/api/v2/job_invocations_controller_test.rb +10 -0
  27. data/test/graphql/queries/job_invocation_query_test.rb +31 -0
  28. data/test/graphql/queries/job_invocations_query_test.rb +35 -0
  29. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  30. data/test/unit/concerns/host_extensions_test.rb +4 -4
  31. data/test/unit/input_template_renderer_test.rb +1 -89
  32. data/test/unit/job_invocation_composer_test.rb +1 -12
  33. data/test/unit/job_invocation_report_template_test.rb +15 -12
  34. data/test/unit/remote_execution_provider_test.rb +34 -0
  35. data/webpack/JobWizard/JobWizard.js +53 -20
  36. data/webpack/JobWizard/JobWizard.scss +33 -4
  37. data/webpack/JobWizard/JobWizardConstants.js +17 -0
  38. data/webpack/JobWizard/__tests__/fixtures.js +8 -0
  39. data/webpack/JobWizard/__tests__/integration.test.js +3 -7
  40. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +16 -5
  41. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +48 -1
  42. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +29 -14
  43. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +4 -2
  44. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +3 -2
  45. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +25 -0
  46. data/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js +23 -0
  47. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js +37 -0
  48. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +50 -0
  49. data/webpack/JobWizard/steps/HostsAndInputs/index.js +66 -0
  50. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  51. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +36 -21
  52. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +155 -0
  53. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +9 -8
  54. data/webpack/JobWizard/steps/Schedule/index.js +89 -28
  55. data/webpack/JobWizard/steps/form/DateTimePicker.js +93 -0
  56. data/webpack/JobWizard/steps/form/Formatter.js +10 -9
  57. data/webpack/JobWizard/steps/form/NumberInput.js +2 -0
  58. data/webpack/JobWizard/steps/form/WizardTitle.js +14 -0
  59. data/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js +43 -0
  60. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +73 -66
  61. data/webpack/react_app/components/RecentJobsCard/RecentJobsTable.js +98 -0
  62. data/webpack/react_app/components/RecentJobsCard/constants.js +11 -0
  63. data/webpack/react_app/components/RecentJobsCard/styles.scss +11 -0
  64. data/webpack/react_app/extend/fillRecentJobsCard.js +1 -1
  65. metadata +26 -19
  66. data/webpack/react_app/components/RecentJobsCard/styles.css +0 -15
@@ -10,24 +10,16 @@ import {
10
10
  testSetup,
11
11
  mockApi,
12
12
  } from '../../../__tests__/fixtures';
13
+ import { WIZARD_TITLES } from '../../../JobWizardConstants';
13
14
 
14
15
  const store = testSetup(selectors, api);
15
16
  mockApi(api);
16
17
 
17
18
  jest.spyOn(selectors, 'selectEffectiveUser');
18
- jest.spyOn(selectors, 'selectTemplateInputs');
19
- jest.spyOn(selectors, 'selectAdvancedTemplateInputs');
20
19
 
21
20
  selectors.selectEffectiveUser.mockImplementation(
22
21
  () => jobTemplate.effective_user
23
22
  );
24
- selectors.selectTemplateInputs.mockImplementation(
25
- () => jobTemplate.template_inputs
26
- );
27
-
28
- selectors.selectAdvancedTemplateInputs.mockImplementation(
29
- () => jobTemplate.advanced_template_inputs
30
- );
31
23
  describe('AdvancedFields', () => {
32
24
  it('should save data between steps for advanced fields', async () => {
33
25
  const wrapper = mount(
@@ -72,7 +64,7 @@ describe('AdvancedFields', () => {
72
64
  .simulate('click');
73
65
 
74
66
  expect(wrapper.find('.pf-c-wizard__nav-link.pf-m-current').text()).toEqual(
75
- 'Target Hosts'
67
+ 'Target hosts and inputs'
76
68
  );
77
69
  wrapper
78
70
  .find('.pf-c-wizard__nav-link')
@@ -91,7 +83,7 @@ describe('AdvancedFields', () => {
91
83
  </Provider>
92
84
  );
93
85
  await act(async () => {
94
- fireEvent.click(screen.getByText('Advanced Fields'));
86
+ fireEvent.click(screen.getByText(WIZARD_TITLES.advanced));
95
87
  });
96
88
  const searchValue = 'search test';
97
89
  const textValue = 'I am a text';
@@ -108,7 +100,7 @@ describe('AdvancedFields', () => {
108
100
  fireEvent.click(selectField);
109
101
  await act(async () => {
110
102
  await fireEvent.click(screen.getByText('option 2'));
111
- fireEvent.click(screen.getAllByText('Advanced Fields')[0]); // to remove focus
103
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.advanced)[0]); // to remove focus
112
104
  await fireEvent.change(textField, {
113
105
  target: { value: textValue },
114
106
  });
@@ -128,9 +120,11 @@ describe('AdvancedFields', () => {
128
120
  expect(searchField.value).toBe(searchValue);
129
121
  expect(dateField.value).toBe(dateValue);
130
122
  await act(async () => {
131
- fireEvent.click(screen.getByText('Category and Template'));
123
+ fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate));
132
124
  });
133
- expect(screen.getAllByText('Category and Template')).toHaveLength(3);
125
+ expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength(
126
+ 3
127
+ );
134
128
 
135
129
  await act(async () => {
136
130
  fireEvent.click(screen.getByText('Advanced Fields'));
@@ -141,4 +135,25 @@ describe('AdvancedFields', () => {
141
135
  expect(screen.queryAllByText('option 1')).toHaveLength(0);
142
136
  expect(screen.queryAllByText('option 2')).toHaveLength(1);
143
137
  });
138
+ it('fill defaults into fields', async () => {
139
+ render(
140
+ <Provider store={store}>
141
+ <JobWizard />
142
+ </Provider>
143
+ );
144
+ await act(async () => {
145
+ fireEvent.click(screen.getByText('Advanced Fields'));
146
+ });
147
+
148
+ expect(
149
+ screen.getByLabelText('effective user', {
150
+ selector: 'input',
151
+ }).value
152
+ ).toBe('default effective user');
153
+ expect(
154
+ screen.getByLabelText('timeout to kill', {
155
+ selector: 'input',
156
+ }).value
157
+ ).toBe('2');
158
+ });
144
159
  });
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Title, Text, TextVariants, Form, Alert } from '@patternfly/react-core';
3
+ import { Text, TextVariants, Form, Alert } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
5
  import { SelectField } from '../form/SelectField';
6
6
  import { GroupedSelectField } from '../form/GroupedSelectField';
7
+ import { WizardTitle } from '../form/WizardTitle';
8
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
7
9
 
8
10
  export const CategoryAndTemplate = ({
9
11
  jobCategories,
@@ -40,7 +42,7 @@ export const CategoryAndTemplate = ({
40
42
  const isError = !!(categoryError || allTemplatesError || templateError);
41
43
  return (
42
44
  <>
43
- <Title headingLevel="h2">{__('Category and Template')}</Title>
45
+ <WizardTitle title={WIZARD_TITLES.categoryAndTemplate} />
44
46
  <Text component={TextVariants.p}>{__('All fields are required.')}</Text>
45
47
  <Form>
46
48
  <SelectField
@@ -5,6 +5,7 @@ import * as api from 'foremanReact/redux/API';
5
5
  import { JobWizard } from '../../JobWizard';
6
6
  import * as selectors from '../../JobWizardSelectors';
7
7
  import { testSetup, mockApi } from '../../__tests__/fixtures';
8
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
8
9
 
9
10
  const store = testSetup(selectors, api);
10
11
  mockApi(api);
@@ -32,7 +33,7 @@ describe('Category And Template', () => {
32
33
  await act(async () => {
33
34
  await fireEvent.click(screen.getByText('Puppet'));
34
35
  });
35
- fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus
36
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus
36
37
  expect(
37
38
  screen.queryAllByLabelText('Ansible Commands', { selector: 'button' })
38
39
  ).toHaveLength(0);
@@ -47,7 +48,7 @@ describe('Category And Template', () => {
47
48
  await act(async () => {
48
49
  await fireEvent.click(screen.getByText('template2'));
49
50
  });
50
- fireEvent.click(screen.getAllByText('Category and Template')[0]); // to remove focus
51
+ fireEvent.click(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)[0]); // to remove focus
51
52
  expect(
52
53
  screen.queryAllByDisplayValue('template1', { selector: 'button' })
53
54
  ).toHaveLength(0);
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Chip, ChipGroup } from '@patternfly/react-core';
4
+
5
+ export const SelectedChips = ({ selected, setSelected }) => {
6
+ const deleteItem = itemToRemove => {
7
+ setSelected(oldSelected =>
8
+ oldSelected.filter(item => item !== itemToRemove)
9
+ );
10
+ };
11
+ return (
12
+ <ChipGroup className="hosts-chip-group">
13
+ {selected.map(chip => (
14
+ <Chip key={chip} id={chip} onClick={() => deleteItem(chip)}>
15
+ {chip}
16
+ </Chip>
17
+ ))}
18
+ </ChipGroup>
19
+ );
20
+ };
21
+
22
+ SelectedChips.propTypes = {
23
+ selected: PropTypes.array.isRequired,
24
+ setSelected: PropTypes.func.isRequired,
25
+ };
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { formatter } from '../form/Formatter';
5
+
6
+ export const TemplateInputs = ({ inputs, value, setValue }) => {
7
+ if (inputs.length)
8
+ return inputs.map(input => formatter(input, value, setValue));
9
+ return (
10
+ <p className="gray-text">
11
+ {__('There are no available input fields for the selected template.')}
12
+ </p>
13
+ );
14
+ };
15
+ TemplateInputs.propTypes = {
16
+ inputs: PropTypes.array.isRequired,
17
+ value: PropTypes.object,
18
+ setValue: PropTypes.func.isRequired,
19
+ };
20
+
21
+ TemplateInputs.defaultProps = {
22
+ value: {},
23
+ };
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import * as api from 'foremanReact/redux/API';
5
+ import { JobWizard } from '../../../JobWizard';
6
+ import * as selectors from '../../../JobWizardSelectors';
7
+ import { testSetup, mockApi } from '../../../__tests__/fixtures';
8
+
9
+ const store = testSetup(selectors, api);
10
+ mockApi(api);
11
+
12
+ describe('TemplateInputs', () => {
13
+ it('should save data between steps for template input fields', async () => {
14
+ render(
15
+ <Provider store={store}>
16
+ <JobWizard advancedValues={{}} setAdvancedValues={jest.fn()} />
17
+ </Provider>
18
+ );
19
+ await act(async () => {
20
+ await fireEvent.click(
21
+ screen.getByText('Target hosts and inputs', { selector: 'button' })
22
+ );
23
+ });
24
+
25
+ expect(
26
+ screen.getAllByLabelText('host2', { selector: 'button' })
27
+ ).toHaveLength(1);
28
+ const chip1 = screen.getByLabelText('host1', { selector: 'button' });
29
+ fireEvent.click(chip1);
30
+ expect(
31
+ screen.queryAllByLabelText('host1', { selector: 'button' })
32
+ ).toHaveLength(0);
33
+ expect(
34
+ screen.queryAllByLabelText('host2', { selector: 'button' })
35
+ ).toHaveLength(1);
36
+ });
37
+ });
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { fireEvent, screen, render, act } from '@testing-library/react';
4
+ import * as api from 'foremanReact/redux/API';
5
+ import { JobWizard } from '../../../JobWizard';
6
+ import * as selectors from '../../../JobWizardSelectors';
7
+ import { testSetup, mockApi } from '../../../__tests__/fixtures';
8
+ import { WIZARD_TITLES } from '../../../JobWizardConstants';
9
+
10
+ const store = testSetup(selectors, api);
11
+ mockApi(api);
12
+
13
+ describe('TemplateInputs', () => {
14
+ it('should save data between steps for template input fields', async () => {
15
+ render(
16
+ <Provider store={store}>
17
+ <JobWizard />
18
+ </Provider>
19
+ );
20
+ await act(async () => {
21
+ fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
22
+ });
23
+ const textValue = 'I am a plain text';
24
+ const textField = screen.getByLabelText('plain hidden', {
25
+ selector: 'textarea',
26
+ });
27
+
28
+ await act(async () => {
29
+ await fireEvent.change(textField, {
30
+ target: { value: textValue },
31
+ });
32
+ });
33
+ expect(
34
+ screen.getByLabelText('plain hidden', {
35
+ selector: 'textarea',
36
+ }).value
37
+ ).toBe(textValue);
38
+ await act(async () => {
39
+ fireEvent.click(screen.getByText(WIZARD_TITLES.categoryAndTemplate));
40
+ });
41
+ expect(screen.getAllByText(WIZARD_TITLES.categoryAndTemplate)).toHaveLength(
42
+ 3
43
+ );
44
+
45
+ await act(async () => {
46
+ fireEvent.click(screen.getByText(WIZARD_TITLES.hostsAndInputs));
47
+ });
48
+ expect(textField.value).toBe(textValue);
49
+ });
50
+ });
@@ -0,0 +1,66 @@
1
+ import React, { useState } from 'react';
2
+ import { Button, Form, FormGroup } from '@patternfly/react-core';
3
+ import PropTypes from 'prop-types';
4
+ import { useSelector } from 'react-redux';
5
+ import { translate as __ } from 'foremanReact/common/I18n';
6
+ import { selectTemplateInputs } from '../../JobWizardSelectors';
7
+ import { SelectField } from '../form/SelectField';
8
+ import { SelectedChips } from './SelectedChips';
9
+ import { TemplateInputs } from './TemplateInputs';
10
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
11
+ import { WizardTitle } from '../form/WizardTitle';
12
+
13
+ const HostsAndInputs = ({
14
+ templateValues,
15
+ setTemplateValues,
16
+ selectedHosts,
17
+ setSelectedHosts,
18
+ }) => {
19
+ 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]);
27
+ return (
28
+ <>
29
+ <WizardTitle title={WIZARD_TITLES.hostsAndInputs} />
30
+ <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
+ />
42
+ </FormGroup>
43
+ <span>
44
+ {__('Apply to')}{' '}
45
+ <Button variant="link" isInline>
46
+ {selectedHosts.length} {__('hosts')}
47
+ </Button>
48
+ </span>
49
+ <TemplateInputs
50
+ inputs={templateInputs}
51
+ value={templateValues}
52
+ setValue={setTemplateValues}
53
+ />
54
+ </Form>
55
+ </>
56
+ );
57
+ };
58
+
59
+ HostsAndInputs.propTypes = {
60
+ templateValues: PropTypes.object.isRequired,
61
+ setTemplateValues: PropTypes.func.isRequired,
62
+ selectedHosts: PropTypes.array.isRequired,
63
+ setSelectedHosts: PropTypes.func.isRequired,
64
+ };
65
+
66
+ export default HostsAndInputs;
@@ -1,25 +1,28 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
2
3
  import { FormGroup, Radio } from '@patternfly/react-core';
3
4
  import { translate as __ } from 'foremanReact/common/I18n';
4
5
 
5
- export const ScheduleType = () => {
6
- const [isFuture, setIsFuture] = useState(false);
7
- return (
8
- <FormGroup label={__('Schedule type')} fieldId="schedule-type">
9
- <Radio
10
- isChecked={!isFuture}
11
- name="schedule-type"
12
- onChange={() => setIsFuture(false)}
13
- id="schedule-type-now"
14
- label={__('Execute now')}
15
- />
16
- <Radio
17
- isChecked={isFuture}
18
- name="schedule-type"
19
- onChange={() => setIsFuture(true)}
20
- id="schedule-type-future"
21
- label={__('Schedule for future execution')}
22
- />
23
- </FormGroup>
24
- );
6
+ export const ScheduleType = ({ isFuture, setIsFuture }) => (
7
+ <FormGroup label={__('Schedule type')} fieldId="schedule-type">
8
+ <Radio
9
+ isChecked={!isFuture}
10
+ name="schedule-type"
11
+ onChange={() => setIsFuture(false)}
12
+ id="schedule-type-now"
13
+ label={__('Execute now')}
14
+ />
15
+ <Radio
16
+ isChecked={isFuture}
17
+ name="schedule-type"
18
+ onChange={() => setIsFuture(true)}
19
+ id="schedule-type-future"
20
+ label={__('Schedule for future execution')}
21
+ />
22
+ </FormGroup>
23
+ );
24
+
25
+ ScheduleType.propTypes = {
26
+ isFuture: PropTypes.bool.isRequired,
27
+ setIsFuture: PropTypes.func.isRequired,
25
28
  };
@@ -1,35 +1,48 @@
1
- import React, { useState } from 'react';
1
+ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { FormGroup, TextInput, Checkbox } from '@patternfly/react-core';
3
+ import { FormGroup, Checkbox } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
+ import { DateTimePicker } from '../form/DateTimePicker';
5
6
 
6
- // TODO: change to datepicker
7
- export const StartEndDates = ({ starts, setStarts, ends, setEnds }) => {
8
- const [isNeverEnds, setIsNeverEnds] = useState(false);
7
+ export const StartEndDates = ({
8
+ starts,
9
+ setStarts,
10
+ ends,
11
+ setEnds,
12
+ isNeverEnds,
13
+ setIsNeverEnds,
14
+ }) => {
9
15
  const toggleIsNeverEnds = (checked, event) => {
10
16
  const value = event?.target?.checked;
11
17
  setIsNeverEnds(value);
12
- setEnds('');
18
+ };
19
+ const validateEndDate = () => {
20
+ if (isNeverEnds) return 'success';
21
+ if (!starts || !ends) return 'success';
22
+ if (new Date(starts).getTime() <= new Date(ends).getTime())
23
+ return 'success';
24
+ return 'error';
13
25
  };
14
26
  return (
15
27
  <>
16
- <FormGroup label={__('Starts')} fieldId="start-date">
17
- <TextInput
18
- id="start-date"
19
- value={starts}
20
- type="text"
21
- onChange={newValue => setStarts(newValue)}
22
- placeholder="mm/dd/yy, hh:mm UTC"
23
- />
28
+ <FormGroup
29
+ className="start-date"
30
+ label={__('Starts')}
31
+ fieldId="start-date"
32
+ >
33
+ <DateTimePicker dateTime={starts} setDateTime={setStarts} />
24
34
  </FormGroup>
25
- <FormGroup label={__('Ends')} fieldId="end-date">
26
- <TextInput
35
+ <FormGroup
36
+ className="end-date"
37
+ label={__('Ends')}
38
+ fieldId="end-date"
39
+ helperTextInvalid={__('End time needs to be after start time')}
40
+ validated={validateEndDate()}
41
+ >
42
+ <DateTimePicker
43
+ dateTime={ends}
44
+ setDateTime={setEnds}
27
45
  isDisabled={isNeverEnds}
28
- id="end-date"
29
- value={ends}
30
- type="text"
31
- onChange={newValue => setEnds(newValue)}
32
- placeholder="mm/dd/yy, hh:mm UTC"
33
46
  />
34
47
  <Checkbox
35
48
  label={__('Never ends')}
@@ -48,4 +61,6 @@ StartEndDates.propTypes = {
48
61
  setStarts: PropTypes.func.isRequired,
49
62
  ends: PropTypes.string.isRequired,
50
63
  setEnds: PropTypes.func.isRequired,
64
+ isNeverEnds: PropTypes.bool.isRequired,
65
+ setIsNeverEnds: PropTypes.func.isRequired,
51
66
  };