foreman-tasks 11.1.0 → 12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6323c04ba353b2c7db536b30690adf3e03648858e9883ba31276a217acedaaa5
4
- data.tar.gz: 521d94f6dfc0d3fba10057376aeafa6b71dc5605b19dd9ed07d08e7505b982c2
3
+ metadata.gz: 7ca02aa356eb223ac95653b1a27efcdf2b8b38a7dca41f947e9c29c7655632cb
4
+ data.tar.gz: c67e4b53d065a81e9744919e90b63743b8919d56116313e18ad468db0fac1be9
5
5
  SHA512:
6
- metadata.gz: ef93f73cb3325aa63c06b392ec9abb20825d27a71388667a29eddc242365407d3be292cabcba7bb6340329140f14d28eee4bb2c66219cf109e11f8f5af0c7a2a
7
- data.tar.gz: 4a30e8776e8938bc75f1a3b88093d7d6707324abd41942c0e9e92efc39c2c85da6d322e7424b71b389f2c4e617fd4d4bdfa1aea587aa4bc72d918bf9c0a02148
6
+ metadata.gz: a9380c9445247ccbca6896e389762c5c5f7356bfc876adad55b29adc2d75339ecc3a572274650c34ef67e363e396860d75edebb8d41884940e4de51564dbfc7f
7
+ data.tar.gz: 40edbd36d1f8dd923ec796497052e934361294835444eea0eff9a479303778cb21a3d2c73ac7e336418b7eb4994eb372133de564011916a654f417e54612f0d0
@@ -0,0 +1,4 @@
1
+ object @task
2
+
3
+ attributes :id, :action, :state, :result
4
+ node(:humanized) { @task.humanized[:action] }
@@ -18,3 +18,23 @@ node(:links) do
18
18
  end
19
19
  node(:username_path) { username_link_task(@task.owner, @task.username) }
20
20
  node(:dynflow_enable_console) { Setting['dynflow_enable_console'] }
21
+ node(:depends_on) do
22
+ if @task.execution_plan
23
+ dynflow_uuids = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(@task.execution_plan.id)
24
+ ForemanTasks::Task.where(external_id: dynflow_uuids).map do |task|
25
+ partial('foreman_tasks/api/tasks/dependency_summary', :object => task)
26
+ end
27
+ else
28
+ []
29
+ end
30
+ end
31
+ node(:blocks) do
32
+ if @task.execution_plan
33
+ dynflow_uuids = ForemanTasks.dynflow.world.persistence.find_blocked_execution_plans(@task.execution_plan.id)
34
+ ForemanTasks::Task.where(external_id: dynflow_uuids).map do |task|
35
+ partial('foreman_tasks/api/tasks/dependency_summary', :object => task)
36
+ end
37
+ else
38
+ []
39
+ end
40
+ end
@@ -17,8 +17,10 @@ module ForemanTasks
17
17
  end
18
18
 
19
19
  initializer 'foreman_tasks.register_plugin', :before => :finisher_hook do
20
+ require 'foreman/cron'
21
+
20
22
  Foreman::Plugin.register :"foreman-tasks" do
21
- requires_foreman '>= 3.15'
23
+ requires_foreman '>= 3.19'
22
24
  divider :top_menu, :parent => :monitor_menu, :last => true, :caption => N_('Foreman Tasks')
23
25
  menu :top_menu, :tasks,
24
26
  :url_hash => { :controller => 'foreman_tasks/tasks', :action => :index },
@@ -124,6 +126,9 @@ module ForemanTasks
124
126
  widget 'foreman_tasks/tasks/dashboard/latest_tasks_in_error_warning', :sizex => 6, :sizey => 1, :name => N_('Latest Warning/Error Tasks')
125
127
 
126
128
  register_gettext domain: "foreman_tasks"
129
+
130
+ # Register recurring task with Foreman::Cron framework
131
+ Foreman::Cron.register(:daily, 'foreman_tasks:cleanup')
127
132
  end
128
133
  end
129
134
 
@@ -25,5 +25,9 @@ module ForemanTasks
25
25
  def delay(action, delay_options, *args)
26
26
  foreman_tasks.delay(action, delay_options, *args)
27
27
  end
28
+
29
+ def chain(dependencies, action, *args)
30
+ foreman_tasks.chain(dependencies, action, *args)
31
+ end
28
32
  end
29
33
  end
@@ -1,3 +1,3 @@
1
1
  module ForemanTasks
2
- VERSION = '11.1.0'.freeze
2
+ VERSION = '12.0.0'.freeze
3
3
  end
data/lib/foreman_tasks.rb CHANGED
@@ -62,6 +62,30 @@ module ForemanTasks
62
62
  ForemanTasks::Task::DynflowTask.where(:external_id => result.id).first!
63
63
  end
64
64
 
65
+ # Chain a task to wait for dependency task(s) to finish before executing.
66
+ # The chained task remains 'scheduled' until all dependencies reach 'stopped' state.
67
+ #
68
+ # @param dependencies [ForemanTasks::Task, Array<ForemanTasks::Task>, ActiveRecord::Relation]
69
+ # Dependency ForemanTasks task object(s) or an ActiveRecord relation of tasks.
70
+ # @param action [Class] Action class to execute
71
+ # @param args Arguments to pass to the action
72
+ # @return [ForemanTasks::Task::DynflowTask] The chained task
73
+ def self.chain(dependencies, action, *args)
74
+ plan_uuids =
75
+ if dependencies.is_a?(ActiveRecord::Relation)
76
+ dependencies.pluck(:external_id)
77
+ else
78
+ Array(dependencies).map(&:external_id)
79
+ end
80
+
81
+ if plan_uuids.any?(&:blank?)
82
+ raise ArgumentError, 'All dependency tasks must have external_id set'
83
+ end
84
+
85
+ result = dynflow.world.chain(plan_uuids, action, *args)
86
+ ForemanTasks::Task::DynflowTask.where(:external_id => result.id).first!
87
+ end
88
+
65
89
  def self.register_scheduled_task(task_class, cronline)
66
90
  ForemanTasks::RecurringLogic.transaction(isolation: :serializable) do
67
91
  return if ForemanTasks::RecurringLogic.joins(:tasks)
@@ -0,0 +1,62 @@
1
+ require 'foreman_tasks_test_helper'
2
+
3
+ module ForemanTasks
4
+ class ChainingTest < ActiveSupport::TestCase
5
+ include ForemanTasks::TestHelpers::WithInThreadExecutor
6
+
7
+ before do
8
+ User.current = User.where(:login => 'apiadmin').first
9
+ end
10
+
11
+ it 'creates a scheduled task chained to a dependency task' do
12
+ triggered = ForemanTasks.trigger(Support::DummyDynflowAction)
13
+ triggered.finished.wait(30)
14
+ dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id)
15
+
16
+ task = ForemanTasks.chain(dependency_task, Support::DummyDynflowAction)
17
+
18
+ assert_kind_of ForemanTasks::Task::DynflowTask, task
19
+ assert_predicate task, :scheduled?
20
+
21
+ dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id)
22
+ assert_includes dependencies, dependency_task.external_id
23
+ end
24
+
25
+ it 'accepts multiple dependency tasks' do
26
+ triggered_1 = ForemanTasks.trigger(Support::DummyDynflowAction)
27
+ triggered_2 = ForemanTasks.trigger(Support::DummyDynflowAction)
28
+ triggered_1.finished.wait(30)
29
+ triggered_2.finished.wait(30)
30
+ dependency_task_1 = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered_1.id)
31
+ dependency_task_2 = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered_2.id)
32
+
33
+ task = ForemanTasks.chain([dependency_task_1, dependency_task_2], Support::DummyDynflowAction)
34
+
35
+ dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id)
36
+ assert_includes dependencies, dependency_task_1.external_id
37
+ assert_includes dependencies, dependency_task_2.external_id
38
+ end
39
+
40
+ it 'accepts dependency task objects' do
41
+ triggered = ForemanTasks.trigger(Support::DummyDynflowAction)
42
+ triggered.finished.wait(30)
43
+ dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id)
44
+
45
+ task = ForemanTasks.chain(dependency_task, Support::DummyDynflowAction)
46
+
47
+ dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id)
48
+ assert_includes dependencies, dependency_task.external_id
49
+ end
50
+
51
+ it 'accepts dependency tasks as a relation' do
52
+ triggered = ForemanTasks.trigger(Support::DummyDynflowAction)
53
+ triggered.finished.wait(30)
54
+ dependency_task = ForemanTasks::Task::DynflowTask.find_by!(:external_id => triggered.id)
55
+
56
+ task = ForemanTasks.chain(ForemanTasks::Task::DynflowTask.where(:id => dependency_task.id), Support::DummyDynflowAction)
57
+
58
+ dependencies = ForemanTasks.dynflow.world.persistence.find_execution_plan_dependencies(task.execution_plan.id)
59
+ assert_includes dependencies, dependency_task.external_id
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,93 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ Alert,
5
+ AlertVariant,
6
+ Grid,
7
+ GridItem,
8
+ Title,
9
+ } from '@patternfly/react-core';
10
+ import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
11
+ import { translate as __ } from 'foremanReact/common/I18n';
12
+
13
+ const DependencyTable = ({ title, tasks }) => {
14
+ const tableId = title.toLowerCase().replace(/\s+/g, '-');
15
+ return (
16
+ <div>
17
+ <Title headingLevel="h4" size="md" ouiaId={`${tableId}-title`}>
18
+ {title}
19
+ </Title>
20
+ {tasks.length === 0 ? (
21
+ <p className="text-muted">{__('None')}</p>
22
+ ) : (
23
+ <Table aria-label={title} variant="compact" ouiaId={`${tableId}-table`}>
24
+ <Thead>
25
+ <Tr ouiaId={`${tableId}-table-header`}>
26
+ <Th width={50}>{__('Action')}</Th>
27
+ <Th width={25}>{__('State')}</Th>
28
+ <Th width={25}>{__('Result')}</Th>
29
+ </Tr>
30
+ </Thead>
31
+ <Tbody>
32
+ {tasks.map(task => (
33
+ <Tr key={task.id} ouiaId={`${tableId}-table-row-${task.id}`}>
34
+ <Td>
35
+ <a href={`/foreman_tasks/tasks/${task.id}`}>
36
+ {task.humanized || task.action}
37
+ </a>
38
+ </Td>
39
+ <Td>{task.state}</Td>
40
+ <Td>{task.result}</Td>
41
+ </Tr>
42
+ ))}
43
+ </Tbody>
44
+ </Table>
45
+ )}
46
+ </div>
47
+ );
48
+ };
49
+
50
+ DependencyTable.propTypes = {
51
+ title: PropTypes.string.isRequired,
52
+ tasks: PropTypes.array,
53
+ };
54
+
55
+ DependencyTable.defaultProps = {
56
+ tasks: [],
57
+ };
58
+
59
+ const Dependencies = ({ dependsOn, blocks }) => (
60
+ <div>
61
+ <Alert
62
+ variant={AlertVariant.info}
63
+ isInline
64
+ title={__('Task dependencies')}
65
+ ouiaId="task-dependencies-info-alert"
66
+ >
67
+ {__(
68
+ 'This task may have dependencies on other tasks or may be blocking other tasks from executing. Dependencies are established through task chaining relationships.'
69
+ )}
70
+ </Alert>
71
+ <br />
72
+ <Grid hasGutter>
73
+ <GridItem span={6}>
74
+ <DependencyTable title={__('Depends on')} tasks={dependsOn} />
75
+ </GridItem>
76
+ <GridItem span={6}>
77
+ <DependencyTable title={__('Blocks')} tasks={blocks} />
78
+ </GridItem>
79
+ </Grid>
80
+ </div>
81
+ );
82
+
83
+ Dependencies.propTypes = {
84
+ dependsOn: PropTypes.array,
85
+ blocks: PropTypes.array,
86
+ };
87
+
88
+ Dependencies.defaultProps = {
89
+ dependsOn: [],
90
+ blocks: [],
91
+ };
92
+
93
+ export default Dependencies;
@@ -0,0 +1,92 @@
1
+ import React from 'react';
2
+ import { mount } from 'enzyme';
3
+ import Dependencies from '../Dependencies';
4
+
5
+ describe('Dependencies', () => {
6
+ it('should render with no dependencies', () => {
7
+ const wrapper = mount(<Dependencies dependsOn={[]} blocks={[]} />);
8
+ expect(wrapper.find('Alert')).toHaveLength(1);
9
+ expect(wrapper.find('DependencyTable')).toHaveLength(2);
10
+ expect(wrapper.find('Table')).toHaveLength(0);
11
+ expect(wrapper.text()).toContain('None');
12
+ });
13
+
14
+ it('should render with depends_on dependencies', () => {
15
+ const dependsOn = [
16
+ {
17
+ id: '123',
18
+ action: 'Actions::FooBar',
19
+ humanized: 'Foo Bar Action',
20
+ state: 'stopped',
21
+ result: 'success',
22
+ },
23
+ {
24
+ id: '456',
25
+ action: 'Actions::BazQux',
26
+ humanized: 'Baz Qux Action',
27
+ state: 'running',
28
+ result: 'pending',
29
+ },
30
+ ];
31
+ const wrapper = mount(<Dependencies dependsOn={dependsOn} blocks={[]} />);
32
+ expect(wrapper.find('Table')).toHaveLength(1);
33
+ expect(wrapper.find('Tbody').find('Tr')).toHaveLength(2);
34
+ expect(wrapper.text()).toContain('Foo Bar Action');
35
+ expect(wrapper.text()).toContain('Baz Qux Action');
36
+ expect(wrapper.text()).toContain('stopped');
37
+ expect(wrapper.text()).toContain('success');
38
+ });
39
+
40
+ it('should render with blocks dependencies', () => {
41
+ const blocks = [
42
+ {
43
+ id: '789',
44
+ action: 'Actions::Test',
45
+ humanized: 'Test Action',
46
+ state: 'paused',
47
+ result: 'warning',
48
+ },
49
+ ];
50
+ const wrapper = mount(<Dependencies dependsOn={[]} blocks={blocks} />);
51
+ expect(wrapper.find('Table')).toHaveLength(1);
52
+ expect(wrapper.find('Tbody').find('Tr')).toHaveLength(1);
53
+ expect(wrapper.text()).toContain('Test Action');
54
+ expect(wrapper.text()).toContain('paused');
55
+ expect(wrapper.text()).toContain('warning');
56
+ });
57
+
58
+ it('should render with both dependency types', () => {
59
+ const dependsOn = [
60
+ {
61
+ id: '123',
62
+ action: 'Actions::Foo',
63
+ humanized: 'Foo Action',
64
+ state: 'stopped',
65
+ result: 'success',
66
+ },
67
+ ];
68
+ const blocks = [
69
+ {
70
+ id: '456',
71
+ action: 'Actions::Bar',
72
+ humanized: 'Bar Action',
73
+ state: 'running',
74
+ result: 'pending',
75
+ },
76
+ {
77
+ id: '789',
78
+ action: 'Actions::Baz',
79
+ humanized: 'Baz Action',
80
+ state: 'stopped',
81
+ result: 'error',
82
+ },
83
+ ];
84
+ const wrapper = mount(
85
+ <Dependencies dependsOn={dependsOn} blocks={blocks} />
86
+ );
87
+ expect(wrapper.find('Table')).toHaveLength(2);
88
+ expect(wrapper.text()).toContain('Foo Action');
89
+ expect(wrapper.text()).toContain('Bar Action');
90
+ expect(wrapper.text()).toContain('Baz Action');
91
+ });
92
+ });
@@ -9,6 +9,7 @@ import RunningSteps from './Components/RunningSteps';
9
9
  import Errors from './Components/Errors';
10
10
  import Locks from './Components/Locks';
11
11
  import Raw from './Components/Raw';
12
+ import Dependencies from './Components/Dependencies';
12
13
  import { getTaskID } from './TasksDetailsHelper';
13
14
  import { TaskSkeleton } from './Components/TaskSkeleton';
14
15
 
@@ -20,6 +21,8 @@ const TaskDetails = ({
20
21
  runningSteps,
21
22
  locks,
22
23
  links,
24
+ dependsOn,
25
+ blocks,
23
26
  cancelStep,
24
27
  taskReloadStart,
25
28
  taskReloadStop,
@@ -90,7 +93,10 @@ const TaskDetails = ({
90
93
  <Tab eventKey={4} disabled={isLoading} title={__('Locks')}>
91
94
  <Locks locks={locks.concat(links)} />
92
95
  </Tab>
93
- <Tab eventKey={5} disabled={isLoading} title={__('Raw')}>
96
+ <Tab eventKey={5} disabled={isLoading} title={__('Dependencies')}>
97
+ <Dependencies dependsOn={dependsOn} blocks={blocks} />
98
+ </Tab>
99
+ <Tab eventKey={6} disabled={isLoading} title={__('Raw')}>
94
100
  <Raw
95
101
  id={id}
96
102
  label={props.label}
@@ -116,9 +122,12 @@ TaskDetails.propTypes = {
116
122
  taskReloadStop: PropTypes.func.isRequired,
117
123
  taskReloadStart: PropTypes.func.isRequired,
118
124
  links: PropTypes.array,
125
+ dependsOn: PropTypes.array,
126
+ blocks: PropTypes.array,
119
127
  ...Task.propTypes,
120
128
  ...Errors.propTypes,
121
129
  ...Locks.propTypes,
130
+ ...Dependencies.propTypes,
122
131
  ...Raw.propTypes,
123
132
  };
124
133
  TaskDetails.defaultProps = {
@@ -127,10 +136,13 @@ TaskDetails.defaultProps = {
127
136
  APIerror: null,
128
137
  status: STATUS.PENDING,
129
138
  links: [],
139
+ dependsOn: [],
140
+ blocks: [],
130
141
  ...Task.defaultProps,
131
142
  ...RunningSteps.defaultProps,
132
143
  ...Errors.defaultProps,
133
144
  ...Locks.defaultProps,
145
+ ...Dependencies.defaultProps,
134
146
  ...Raw.defaultProps,
135
147
  };
136
148
 
@@ -109,3 +109,9 @@ export const selectAPIError = state =>
109
109
  export const selectIsLoading = state =>
110
110
  !!selectAPIByKey(state, FOREMAN_TASK_DETAILS).response &&
111
111
  selectStatus(state) === STATUS.PENDING;
112
+
113
+ export const selectDependsOn = state =>
114
+ selectTaskDetailsResponse(state).depends_on || [];
115
+
116
+ export const selectBlocks = state =>
117
+ selectTaskDetailsResponse(state).blocks || [];
@@ -58,6 +58,16 @@ exports[`TaskDetails rendering render with loading Props 1`] = `
58
58
  <Tab
59
59
  disabled={true}
60
60
  eventKey={5}
61
+ title="Dependencies"
62
+ >
63
+ <Dependencies
64
+ blocks={Array []}
65
+ dependsOn={Array []}
66
+ />
67
+ </Tab>
68
+ <Tab
69
+ disabled={true}
70
+ eventKey={6}
61
71
  title="Raw"
62
72
  >
63
73
  <Raw
@@ -136,6 +146,15 @@ exports[`TaskDetails rendering render with min Props 1`] = `
136
146
  </Tab>
137
147
  <Tab
138
148
  eventKey={5}
149
+ title="Dependencies"
150
+ >
151
+ <Dependencies
152
+ blocks={Array []}
153
+ dependsOn={Array []}
154
+ />
155
+ </Tab>
156
+ <Tab
157
+ eventKey={6}
139
158
  title="Raw"
140
159
  >
141
160
  <Raw
@@ -36,6 +36,8 @@ import {
36
36
  selectStatus,
37
37
  selectAPIError,
38
38
  selectIsLoading,
39
+ selectDependsOn,
40
+ selectBlocks,
39
41
  } from './TaskDetailsSelectors';
40
42
 
41
43
  const mapStateToProps = state => ({
@@ -71,6 +73,8 @@ const mapStateToProps = state => ({
71
73
  status: selectStatus(state),
72
74
  APIerror: selectAPIError(state),
73
75
  isLoading: selectIsLoading(state),
76
+ dependsOn: selectDependsOn(state),
77
+ blocks: selectBlocks(state),
74
78
  });
75
79
 
76
80
  const mapDispatchToProps = dispatch =>
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
- import { DropdownButton, MenuItem } from 'patternfly-react';
3
2
  import PropTypes from 'prop-types';
3
+ import { SimpleDropdown } from '@patternfly/react-templates';
4
+ import { EllipsisVIcon } from '@patternfly/react-icons';
4
5
  import { translate as __ } from 'foremanReact/common/I18n';
5
6
 
6
7
  export const ActionSelectButton = ({
@@ -8,35 +9,35 @@ export const ActionSelectButton = ({
8
9
  onResume,
9
10
  onForceCancel,
10
11
  disabled,
11
- }) => (
12
- <DropdownButton
13
- title={__('Select Action')}
14
- disabled={disabled}
15
- id="selcted-action-type"
16
- >
17
- <MenuItem
18
- title={__('Cancel selected tasks')}
19
- onClick={onCancel}
20
- eventKey="1"
21
- >
22
- {__('Cancel Selected')}
23
- </MenuItem>
24
- <MenuItem
25
- title={__('Resume selected tasks')}
26
- onClick={onResume}
27
- eventKey="2"
28
- >
29
- {__('Resume Selected')}
30
- </MenuItem>
31
- <MenuItem
32
- title={__('Force Cancel selected tasks')}
33
- onClick={onForceCancel}
34
- eventKey="3"
35
- >
36
- {__('Force Cancel Selected')}
37
- </MenuItem>
38
- </DropdownButton>
39
- );
12
+ }) => {
13
+ const buttons = [
14
+ {
15
+ content: __('Cancel Selected'),
16
+ onClick: onCancel,
17
+ value: 1,
18
+ },
19
+ {
20
+ content: __('Resume Selected'),
21
+ onClick: onResume,
22
+ value: 2,
23
+ },
24
+ {
25
+ content: __('Force Cancel Selected'),
26
+ onClick: onForceCancel,
27
+ value: 3,
28
+ },
29
+ ];
30
+ return (
31
+ <SimpleDropdown
32
+ isDisabled={disabled}
33
+ ouiaId="tasks-table-action-select-dropdown"
34
+ toggleVariant="plain"
35
+ popperProps={{ position: 'right' }}
36
+ initialItems={buttons}
37
+ toggleContent={<EllipsisVIcon aria-hidden="true" />}
38
+ />
39
+ );
40
+ };
40
41
 
41
42
  ActionSelectButton.propTypes = {
42
43
  disabled: PropTypes.bool,
@@ -1,14 +1,81 @@
1
- import { testComponentSnapshotsWithFixtures } from '@theforeman/test';
2
-
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
3
4
  import { ActionSelectButton } from '../ActionSelectButton';
4
5
 
5
- const fixtures = {
6
- 'renders with minimal props': {
7
- onCancel: jest.fn(),
8
- onResume: jest.fn(),
9
- onForceCancel: jest.fn(),
10
- },
11
- };
6
+ const mockOnCancel = jest.fn();
7
+ const mockOnResume = jest.fn();
8
+ const mockOnForceCancel = jest.fn();
9
+
10
+ describe('ActionSelectButton', () => {
11
+ const renderComponent = (props = {}) => {
12
+ const defaultProps = {
13
+ onCancel: mockOnCancel,
14
+ onResume: mockOnResume,
15
+ onForceCancel: mockOnForceCancel,
16
+ disabled: false,
17
+ };
18
+ return render(<ActionSelectButton {...defaultProps} {...props} />);
19
+ };
20
+
21
+ beforeEach(() => {
22
+ jest.clearAllMocks();
23
+ });
24
+
25
+ it('renders with minimal props', () => {
26
+ renderComponent();
27
+
28
+ const toggle = screen.getByRole('button');
29
+ expect(toggle).toBeInTheDocument();
30
+ expect(toggle).not.toBeDisabled();
31
+ });
32
+
33
+ it('renders disabled when disabled prop is true', () => {
34
+ renderComponent({ disabled: true });
35
+
36
+ const toggle = screen.getByRole('button');
37
+ expect(toggle).toBeDisabled();
38
+ });
39
+
40
+ it('opens dropdown and shows action options when toggle is clicked', async () => {
41
+ renderComponent();
42
+ const toggle = screen.getByRole('button');
43
+ fireEvent.click(toggle);
44
+
45
+ await waitFor(() => {
46
+ expect(screen.getByText('Cancel Selected')).toBeInTheDocument();
47
+ expect(screen.getByText('Resume Selected')).toBeInTheDocument();
48
+ expect(screen.getByText('Force Cancel Selected')).toBeInTheDocument();
49
+ });
50
+ });
51
+
52
+ it('calls onCancel when Cancel Selected is clicked', async () => {
53
+ renderComponent();
54
+ fireEvent.click(screen.getByRole('button'));
55
+ await waitFor(() => {
56
+ expect(screen.getByText('Cancel Selected')).toBeInTheDocument();
57
+ });
58
+ fireEvent.click(screen.getByText('Cancel Selected'));
59
+ expect(mockOnCancel).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('calls onResume when Resume Selected is clicked', async () => {
63
+ renderComponent();
64
+ fireEvent.click(screen.getByRole('button'));
65
+ await waitFor(() => {
66
+ expect(screen.getByText('Resume Selected')).toBeInTheDocument();
67
+ });
68
+ fireEvent.click(screen.getByText('Resume Selected'));
69
+ expect(mockOnResume).toHaveBeenCalledTimes(1);
70
+ });
12
71
 
13
- describe('ActionSelectButton', () =>
14
- testComponentSnapshotsWithFixtures(ActionSelectButton, fixtures));
72
+ it('calls onForceCancel when Force Cancel Selected is clicked', async () => {
73
+ renderComponent();
74
+ fireEvent.click(screen.getByRole('button'));
75
+ await waitFor(() => {
76
+ expect(screen.getByText('Force Cancel Selected')).toBeInTheDocument();
77
+ });
78
+ fireEvent.click(screen.getByText('Force Cancel Selected'));
79
+ expect(mockOnForceCancel).toHaveBeenCalledTimes(1);
80
+ });
81
+ });
@@ -2,11 +2,11 @@ import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import URI from 'urijs';
4
4
  import { getURIsearch } from 'foremanReact/common/urlHelpers';
5
- import { Spinner, Button, Icon } from 'patternfly-react';
5
+ import { Button, ToolbarItem, Spinner } from '@patternfly/react-core';
6
+ import { RedoIcon } from '@patternfly/react-icons';
6
7
  import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
7
8
  import { translate as __ } from 'foremanReact/common/I18n';
8
9
  import { getURIQuery } from 'foremanReact/common/helpers';
9
- import ExportButton from 'foremanReact/routes/common/PageLayout/components/ExportButton/ExportButton';
10
10
  import { STATUS } from 'foremanReact/constants';
11
11
  import TasksDashboard from '../TasksDashboard';
12
12
  import TasksTable from './TasksTable';
@@ -127,25 +127,44 @@ const TasksTablePage = ({
127
127
  header={createHeader(props.actionName)}
128
128
  breadcrumbOptions={getBreadcrumbs(props.actionName)}
129
129
  toolbarButtons={
130
- <React.Fragment>
131
- <Button onClick={() => props.reloadPage(url, props.parentTaskID)}>
132
- <Icon type="fa" name="refresh" /> {__('Refresh Data')}
133
- </Button>
134
- {props.status === STATUS.PENDING && <Spinner size="md" loading />}
135
- <ExportButton
136
- url={getCSVurl(history.location.pathname, uriQuery)}
137
- title={__('Export All')}
138
- />
139
- <ActionSelectButton
140
- disabled={
141
- !props.permissions.edit ||
142
- !(props.selectedRows.length || props.allRowsSelected)
143
- }
144
- onCancel={() => openModal(CANCEL_SELECTED_MODAL)}
145
- onResume={() => openModal(RESUME_SELECTED_MODAL)}
146
- onForceCancel={() => openModal(FORCE_UNLOCK_SELECTED_MODAL)}
147
- />
148
- </React.Fragment>
130
+ <>
131
+ <ToolbarItem>
132
+ <Button
133
+ ouiaId="tasks-table-refresh-data"
134
+ variant="primary"
135
+ onClick={() => props.reloadPage(url, props.parentTaskID)}
136
+ icon={<RedoIcon />}
137
+ >
138
+ {__('Refresh Data')}
139
+ </Button>
140
+ </ToolbarItem>
141
+ {props.status === STATUS.PENDING && (
142
+ <ToolbarItem>
143
+ <Spinner size="lg" />
144
+ </ToolbarItem>
145
+ )}
146
+ <ToolbarItem>
147
+ <Button
148
+ ouiaId="tasks-table-export-all"
149
+ variant="secondary"
150
+ component="a"
151
+ href={getCSVurl(history.location.pathname, uriQuery)}
152
+ >
153
+ {__('Export All')}
154
+ </Button>
155
+ </ToolbarItem>
156
+ <ToolbarItem>
157
+ <ActionSelectButton
158
+ disabled={
159
+ !props.permissions.edit ||
160
+ !(props.selectedRows.length || props.allRowsSelected)
161
+ }
162
+ onCancel={() => openModal(CANCEL_SELECTED_MODAL)}
163
+ onResume={() => openModal(RESUME_SELECTED_MODAL)}
164
+ onForceCancel={() => openModal(FORCE_UNLOCK_SELECTED_MODAL)}
165
+ />
166
+ </ToolbarItem>
167
+ </>
149
168
  }
150
169
  searchQuery={getURIsearch()}
151
170
  beforeToolbarComponent={
@@ -108,38 +108,39 @@ exports[`TasksTablePage rendering render with Breadcrubs and edit permissions 1`
108
108
  searchable={true}
109
109
  toolbarButtons={
110
110
  <React.Fragment>
111
- <Button
112
- active={false}
113
- block={false}
114
- bsClass="btn"
115
- bsStyle="default"
116
- disabled={false}
117
- onClick={[Function]}
118
- >
119
- <Icon
120
- name="refresh"
121
- type="fa"
111
+ <ToolbarItem>
112
+ <Button
113
+ icon={<RedoIcon />}
114
+ onClick={[Function]}
115
+ ouiaId="tasks-table-refresh-data"
116
+ variant="primary"
117
+ >
118
+ Refresh Data
119
+ </Button>
120
+ </ToolbarItem>
121
+ <ToolbarItem>
122
+ <Spinner
123
+ size="lg"
122
124
  />
123
-
124
- Refresh Data
125
- </Button>
126
- <Spinner
127
- className=""
128
- inline={false}
129
- inverse={false}
130
- loading={true}
131
- size="md"
132
- />
133
- <ExportButton
134
- title="Export All"
135
- url="/foreman_tasks/tasks.csv?search=%28state%3Dstopped%29"
136
- />
137
- <ActionSelectButton
138
- disabled={true}
139
- onCancel={[Function]}
140
- onForceCancel={[Function]}
141
- onResume={[Function]}
142
- />
125
+ </ToolbarItem>
126
+ <ToolbarItem>
127
+ <Button
128
+ component="a"
129
+ href="/foreman_tasks/tasks.csv?search=%28state%3Dstopped%29"
130
+ ouiaId="tasks-table-export-all"
131
+ variant="secondary"
132
+ >
133
+ Export All
134
+ </Button>
135
+ </ToolbarItem>
136
+ <ToolbarItem>
137
+ <ActionSelectButton
138
+ disabled={true}
139
+ onCancel={[Function]}
140
+ onForceCancel={[Function]}
141
+ onResume={[Function]}
142
+ />
143
+ </ToolbarItem>
143
144
  </React.Fragment>
144
145
  }
145
146
  >
@@ -283,38 +284,39 @@ exports[`TasksTablePage rendering render with minimal props 1`] = `
283
284
  searchable={true}
284
285
  toolbarButtons={
285
286
  <React.Fragment>
286
- <Button
287
- active={false}
288
- block={false}
289
- bsClass="btn"
290
- bsStyle="default"
291
- disabled={false}
292
- onClick={[Function]}
293
- >
294
- <Icon
295
- name="refresh"
296
- type="fa"
287
+ <ToolbarItem>
288
+ <Button
289
+ icon={<RedoIcon />}
290
+ onClick={[Function]}
291
+ ouiaId="tasks-table-refresh-data"
292
+ variant="primary"
293
+ >
294
+ Refresh Data
295
+ </Button>
296
+ </ToolbarItem>
297
+ <ToolbarItem>
298
+ <Spinner
299
+ size="lg"
300
+ />
301
+ </ToolbarItem>
302
+ <ToolbarItem>
303
+ <Button
304
+ component="a"
305
+ href="/foreman_tasks/tasks.csv?search=%28state%3Dstopped%29"
306
+ ouiaId="tasks-table-export-all"
307
+ variant="secondary"
308
+ >
309
+ Export All
310
+ </Button>
311
+ </ToolbarItem>
312
+ <ToolbarItem>
313
+ <ActionSelectButton
314
+ disabled={true}
315
+ onCancel={[Function]}
316
+ onForceCancel={[Function]}
317
+ onResume={[Function]}
297
318
  />
298
-
299
- Refresh Data
300
- </Button>
301
- <Spinner
302
- className=""
303
- inline={false}
304
- inverse={false}
305
- loading={true}
306
- size="md"
307
- />
308
- <ExportButton
309
- title="Export All"
310
- url="/foreman_tasks/tasks.csv?search=%28state%3Dstopped%29"
311
- />
312
- <ActionSelectButton
313
- disabled={true}
314
- onCancel={[Function]}
315
- onForceCancel={[Function]}
316
- onResume={[Function]}
317
- />
319
+ </ToolbarItem>
318
320
  </React.Fragment>
319
321
  }
320
322
  >
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 11.1.0
4
+ version: 12.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Nečas
@@ -234,6 +234,7 @@ files:
234
234
  - app/views/foreman_tasks/api/recurring_logics/main.json.rabl
235
235
  - app/views/foreman_tasks/api/recurring_logics/show.json.rabl
236
236
  - app/views/foreman_tasks/api/recurring_logics/update.json.rabl
237
+ - app/views/foreman_tasks/api/tasks/dependency_summary.json.rabl
237
238
  - app/views/foreman_tasks/api/tasks/details.json.rabl
238
239
  - app/views/foreman_tasks/api/tasks/index.json.rabl
239
240
  - app/views/foreman_tasks/api/tasks/show.json.rabl
@@ -377,6 +378,7 @@ files:
377
378
  - test/unit/actions/proxy_action_test.rb
378
379
  - test/unit/actions/recurring_action_test.rb
379
380
  - test/unit/actions/trigger_proxy_batch_test.rb
381
+ - test/unit/chaining_test.rb
380
382
  - test/unit/cleaner_test.rb
381
383
  - test/unit/config/environment.rb
382
384
  - test/unit/dynflow_console_authorizer_test.rb
@@ -398,6 +400,7 @@ files:
398
400
  - webpack/ForemanTasks/Components/TaskActions/TaskActionsConstants.js
399
401
  - webpack/ForemanTasks/Components/TaskActions/__snapshots__/TaskAction.test.js.snap
400
402
  - webpack/ForemanTasks/Components/TaskActions/index.js
403
+ - webpack/ForemanTasks/Components/TaskDetails/Components/Dependencies.js
401
404
  - webpack/ForemanTasks/Components/TaskDetails/Components/Errors.js
402
405
  - webpack/ForemanTasks/Components/TaskDetails/Components/Locks.js
403
406
  - webpack/ForemanTasks/Components/TaskDetails/Components/Raw.js
@@ -407,6 +410,7 @@ files:
407
410
  - webpack/ForemanTasks/Components/TaskDetails/Components/TaskHelper.js
408
411
  - webpack/ForemanTasks/Components/TaskDetails/Components/TaskInfo.js
409
412
  - webpack/ForemanTasks/Components/TaskDetails/Components/TaskSkeleton.js
413
+ - webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Dependencies.test.js
410
414
  - webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Errors.test.js
411
415
  - webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Locks.test.js
412
416
  - webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/Raw.test.js
@@ -510,7 +514,6 @@ files:
510
514
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/SelectAllAlert.test.js
511
515
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/TableSelectionCell.test.js
512
516
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/TableSelectionHeaderCell.test.js
513
- - webpack/ForemanTasks/Components/TasksTable/Components/__test__/__snapshots__/ActionSelectButton.test.js.snap
514
517
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/__snapshots__/SelectAllAlert.test.js.snap
515
518
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/__snapshots__/TableSelectionCell.test.js.snap
516
519
  - webpack/ForemanTasks/Components/TasksTable/Components/__test__/__snapshots__/TableSelectionHeaderCell.test.js.snap
@@ -660,6 +663,7 @@ test_files:
660
663
  - test/unit/actions/proxy_action_test.rb
661
664
  - test/unit/actions/recurring_action_test.rb
662
665
  - test/unit/actions/trigger_proxy_batch_test.rb
666
+ - test/unit/chaining_test.rb
663
667
  - test/unit/cleaner_test.rb
664
668
  - test/unit/config/environment.rb
665
669
  - test/unit/dynflow_console_authorizer_test.rb
@@ -1,43 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`ActionSelectButton renders with minimal props 1`] = `
4
- <DropdownButton
5
- disabled={false}
6
- id="selcted-action-type"
7
- title="Select Action"
8
- >
9
- <MenuItem
10
- bsClass="dropdown"
11
- disabled={false}
12
- divider={false}
13
- eventKey="1"
14
- header={false}
15
- onClick={[MockFunction]}
16
- title="Cancel selected tasks"
17
- >
18
- Cancel Selected
19
- </MenuItem>
20
- <MenuItem
21
- bsClass="dropdown"
22
- disabled={false}
23
- divider={false}
24
- eventKey="2"
25
- header={false}
26
- onClick={[MockFunction]}
27
- title="Resume selected tasks"
28
- >
29
- Resume Selected
30
- </MenuItem>
31
- <MenuItem
32
- bsClass="dropdown"
33
- disabled={false}
34
- divider={false}
35
- eventKey="3"
36
- header={false}
37
- onClick={[MockFunction]}
38
- title="Force Cancel selected tasks"
39
- >
40
- Force Cancel Selected
41
- </MenuItem>
42
- </DropdownButton>
43
- `;