foreman_remote_execution 4.5.5 → 4.8.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 (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
  };