foreman-tasks 1.1.0 → 1.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/controllers/foreman_tasks/api/tasks_controller.rb +12 -5
- data/app/lib/foreman_tasks/concerns/polling_action_extensions.rb +12 -0
- data/app/models/setting/foreman_tasks.rb +6 -1
- data/app/services/ui_notifications/tasks/task_bulk_cancel.rb +36 -0
- data/app/services/ui_notifications/tasks/task_bulk_resume.rb +38 -0
- data/db/seeds.d/30-notification_blueprints.rb +14 -0
- data/foreman-tasks.gemspec +1 -0
- data/gemfile.d/foreman-tasks.rb +1 -0
- data/lib/foreman_tasks/engine.rb +1 -0
- data/lib/foreman_tasks/version.rb +1 -1
- data/script/travis_run_js_tests.sh +2 -2
- data/test/lib/concerns/polling_action_extensions_test.rb +34 -0
- data/webpack/ForemanTasks/Components/TaskDetails/Components/__tests__/__snapshots__/TaskInfo.test.js.snap +1 -3
- data/webpack/ForemanTasks/Components/TasksDashboard/TasksDashboardConstants.js +5 -0
- data/webpack/ForemanTasks/Components/TasksDashboard/TasksDashboardHelper.js +3 -2
- data/webpack/ForemanTasks/Components/TasksTable/Components/SelectAllAlert.js +43 -0
- data/webpack/ForemanTasks/Components/TasksTable/Components/__test__/SelectAllAlert.test.js +29 -0
- data/webpack/ForemanTasks/Components/TasksTable/Components/__test__/__snapshots__/SelectAllAlert.test.js.snap +75 -0
- data/webpack/ForemanTasks/Components/TasksTable/SubTasksPage.js +2 -1
- data/webpack/ForemanTasks/Components/TasksTable/TasksBulkActions.js +164 -0
- data/webpack/ForemanTasks/Components/TasksTable/TasksTable.js +24 -10
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableActionHelpers.js +52 -0
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableActions.js +66 -128
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableConstants.js +11 -1
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableHelpers.js +4 -3
- data/webpack/ForemanTasks/Components/TasksTable/TasksTablePage.js +64 -12
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableReducer.js +21 -2
- data/webpack/ForemanTasks/Components/TasksTable/TasksTableSelectors.js +6 -0
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksBulkActions.test.js +112 -0
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTable.fixtures.js +5 -3
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTableActionHelpers.test.js +46 -0
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTableActions.test.js +19 -41
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTableHelpers.test.js +17 -1
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTablePage.test.js +9 -1
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/TasksTableReducer.test.js +22 -1
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/SubTasksPage.test.js.snap +5 -3
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/TasksBulkActions.test.js.snap +229 -0
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/TasksIndexPage.test.js.snap +5 -3
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/TasksTableActions.test.js.snap +39 -124
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/TasksTablePage.test.js.snap +40 -16
- data/webpack/ForemanTasks/Components/TasksTable/__tests__/__snapshots__/TasksTableReducer.test.js.snap +34 -0
- data/webpack/ForemanTasks/Components/TasksTable/formatters/__test__/selectionHeaderCellFormatter.test.js +1 -1
- data/webpack/ForemanTasks/Components/TasksTable/formatters/selectionHeaderCellFormatter.js +2 -2
- data/webpack/ForemanTasks/Components/TasksTable/index.js +8 -2
- data/webpack/ForemanTasks/Components/common/ToastTypesConstants.js +11 -0
- metadata +31 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c5c591217c85cb51b8bfa6372cac7b6eca2010a6b574da5f85c970ad83392641
|
4
|
+
data.tar.gz: 49e5c517005966476ee6ffca2d61998a8fb9ce89f5c621e8fcecb8ed5dc6917c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c31a093250bca2a31d50c5d9f85dd2c80cb35cfaa4d48a24c96a471c3d218cf0f6cf2b1511074b0bd5c1349d738e49a0cd5f0d5ffeba1d98035958eb02c39fbb
|
7
|
+
data.tar.gz: a548fb2f051c5173066e009539a17d768b353a52ddd66b43816b4d308b910d173b1264b525745621b18023a0111e9c2b25e4508668f406c4df1f82f6c0ec8801
|
@@ -95,7 +95,8 @@ module ForemanTasks
|
|
95
95
|
resumed = []
|
96
96
|
failed = []
|
97
97
|
skipped = []
|
98
|
-
|
98
|
+
filtered_scope = bulk_scope
|
99
|
+
filtered_scope.each do |task|
|
99
100
|
if task.resumable?
|
100
101
|
begin
|
101
102
|
ForemanTasks.dynflow.world.execute(task.execution_plan.id)
|
@@ -107,7 +108,10 @@ module ForemanTasks
|
|
107
108
|
skipped << task_hash(task)
|
108
109
|
end
|
109
110
|
end
|
110
|
-
|
111
|
+
if params[:search]
|
112
|
+
notification = UINotifications::Tasks::TaskBulkResume.new(filtered_scope.first, resumed.length, failed.length, skipped.length)
|
113
|
+
notification.deliver!
|
114
|
+
end
|
111
115
|
render :json => {
|
112
116
|
total: resumed.length + failed.length + skipped.length,
|
113
117
|
resumed: resumed,
|
@@ -123,10 +127,13 @@ module ForemanTasks
|
|
123
127
|
if params[:search].nil? && params[:task_ids].nil?
|
124
128
|
raise BadRequest, _('Please provide at least one of search or task_ids parameters in the request')
|
125
129
|
end
|
126
|
-
|
127
|
-
cancelled, skipped =
|
128
|
-
|
130
|
+
filtered_scope = bulk_scope
|
131
|
+
cancelled, skipped = filtered_scope.partition(&:cancellable?)
|
129
132
|
cancelled.each(&:cancel)
|
133
|
+
if params[:search]
|
134
|
+
notification = UINotifications::Tasks::TaskBulkCancel.new(filtered_scope.first, cancelled.length, skipped.length)
|
135
|
+
notification.deliver!
|
136
|
+
end
|
130
137
|
render :json => {
|
131
138
|
total: cancelled.length + skipped.length,
|
132
139
|
cancelled: cancelled,
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module ForemanTasks
|
2
|
+
module Concerns
|
3
|
+
module PollingActionExtensions
|
4
|
+
def poll_intervals
|
5
|
+
multiplier = Setting[:foreman_tasks_polling_multiplier] || 1
|
6
|
+
|
7
|
+
# Prevent the intervals from going below 0.5 seconds
|
8
|
+
super.map { |interval| [interval * multiplier, 0.5].max }
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -13,7 +13,12 @@ class Setting::ForemanTasks < Setting
|
|
13
13
|
N_('Url pointing to the task troubleshooting documentation. '\
|
14
14
|
'It should contain %{label} placeholder, that will be replaced with normalized task label '\
|
15
15
|
'(restricted to only alphanumeric characters)). %{version} placeholder is also available.'),
|
16
|
-
nil)
|
16
|
+
nil),
|
17
|
+
set('foreman_tasks_polling_multiplier',
|
18
|
+
N_('Polling multiplier which is used to multiply the default polling intervals. '\
|
19
|
+
'This can be used to prevent polling too frequently for long running tasks.'),
|
20
|
+
1,
|
21
|
+
N_("Polling intervals multiplier"))
|
17
22
|
]
|
18
23
|
end
|
19
24
|
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module UINotifications
|
2
|
+
module Tasks
|
3
|
+
class TaskBulkCancel < ::UINotifications::Base
|
4
|
+
def initialize(task, cancelled_length, skipped_length)
|
5
|
+
@subject = task
|
6
|
+
@cancelled_length = cancelled_length
|
7
|
+
@skipped_length = skipped_length
|
8
|
+
end
|
9
|
+
|
10
|
+
def create
|
11
|
+
Notification.create!(
|
12
|
+
initiator: initiator,
|
13
|
+
audience: audience,
|
14
|
+
subject: subject,
|
15
|
+
notification_blueprint: blueprint,
|
16
|
+
message: message,
|
17
|
+
notification_recipients: [NotificationRecipient.create({ :user => User.current })]
|
18
|
+
)
|
19
|
+
end
|
20
|
+
|
21
|
+
def audience
|
22
|
+
Notification::AUDIENCE_GLOBAL
|
23
|
+
end
|
24
|
+
|
25
|
+
def message
|
26
|
+
('%{cancelled} Tasks were cancelled. %{skipped} Tasks were skipped. ' %
|
27
|
+
{ cancelled: @cancelled_length,
|
28
|
+
skipped: @skipped_length })
|
29
|
+
end
|
30
|
+
|
31
|
+
def blueprint
|
32
|
+
@blueprint ||= NotificationBlueprint.find_by(name: 'tasks_bulk_cancel')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module UINotifications
|
2
|
+
module Tasks
|
3
|
+
class TaskBulkResume < ::UINotifications::Base
|
4
|
+
def initialize(task, resumed_length, failed_length, skipped_length)
|
5
|
+
@subject = task
|
6
|
+
@resumed_length = resumed_length
|
7
|
+
@failed_length = failed_length
|
8
|
+
@skipped_length = skipped_length
|
9
|
+
end
|
10
|
+
|
11
|
+
def create
|
12
|
+
Notification.create!(
|
13
|
+
initiator: initiator,
|
14
|
+
audience: audience,
|
15
|
+
subject: subject,
|
16
|
+
notification_blueprint: blueprint,
|
17
|
+
message: message,
|
18
|
+
notification_recipients: [NotificationRecipient.create({ :user => User.current })]
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def audience
|
23
|
+
Notification::AUDIENCE_USER
|
24
|
+
end
|
25
|
+
|
26
|
+
def message
|
27
|
+
('%{resumed} Tasks were resumed. %{failed} Tasks failed to resume. %{skipped} Tasks were skipped. ' %
|
28
|
+
{ resumed: @resumed_length,
|
29
|
+
failed: @failed_length,
|
30
|
+
skipped: @skipped_length })
|
31
|
+
end
|
32
|
+
|
33
|
+
def blueprint
|
34
|
+
@blueprint ||= NotificationBlueprint.find_by(name: 'tasks_bulk_resume')
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -27,6 +27,20 @@ blueprints = [
|
|
27
27
|
title: N_('Task Details')
|
28
28
|
]
|
29
29
|
}
|
30
|
+
},
|
31
|
+
|
32
|
+
{
|
33
|
+
group: N_('Tasks'),
|
34
|
+
name: 'tasks_bulk_resume',
|
35
|
+
level: 'info',
|
36
|
+
message: "DYNAMIC",
|
37
|
+
},
|
38
|
+
|
39
|
+
{
|
40
|
+
group: N_('Tasks'),
|
41
|
+
name: 'tasks_bulk_cancel',
|
42
|
+
level: 'info',
|
43
|
+
message: "DYNAMIC",
|
30
44
|
}
|
31
45
|
]
|
32
46
|
|
data/foreman-tasks.gemspec
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
gem 'sqlite3'
|
data/lib/foreman_tasks/engine.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/bin/bash
|
2
2
|
set -ev
|
3
|
-
if [[ $( git diff --name-only HEAD~1..HEAD webpack/ .travis.yml .
|
3
|
+
if [[ $( git diff --name-only HEAD~1..HEAD webpack/ .travis.yml babel.config.js .eslintrc package.json | wc -l ) -ne 0 ]]; then
|
4
4
|
npm run test;
|
5
|
-
npm run
|
5
|
+
npm run publish-coverage;
|
6
6
|
npm run lint;
|
7
7
|
fi
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'foreman_tasks_test_helper'
|
2
|
+
|
3
|
+
module ForemanTasks
|
4
|
+
module Concerns
|
5
|
+
class PollingActionExtensionsTest < ::ActiveSupport::TestCase
|
6
|
+
class Action < ::Dynflow::Action
|
7
|
+
include ::Dynflow::Action::Polling
|
8
|
+
end
|
9
|
+
|
10
|
+
describe 'polling interval tuning' do
|
11
|
+
let(:default_intervals) { [0.5, 1, 2, 4, 8, 16] }
|
12
|
+
|
13
|
+
it 'is extends the polling action module' do
|
14
|
+
_(::Dynflow::Action::Polling.ancestors.first).must_equal ForemanTasks::Concerns::PollingActionExtensions
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'does not modify polling intervals by default' do
|
18
|
+
_(Action.allocate.poll_intervals).must_equal default_intervals
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'cannot make intervals shorter than 0.5 seconds' do
|
22
|
+
Setting.expects(:[]).with(:foreman_tasks_polling_multiplier).returns 0
|
23
|
+
_(Action.allocate.poll_intervals).must_equal(default_intervals.map { 0.5 })
|
24
|
+
end
|
25
|
+
|
26
|
+
it 'can be used to make the intervals longer' do
|
27
|
+
value = 5
|
28
|
+
Setting.expects(:[]).with(:foreman_tasks_polling_multiplier).returns value
|
29
|
+
_(Action.allocate.poll_intervals).must_equal(default_intervals.map { |i| i * value })
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -292,9 +292,7 @@ exports[`TaskInfo rendering render with Props 1`] = `
|
|
292
292
|
</p>
|
293
293
|
<p>
|
294
294
|
A paused task represents a process that has not finished properly. Any task in paused state can lead to potential inconsistency and needs to be resolved.
|
295
|
-
|
296
|
-
The recommended approach is to investigate the error messages below and in 'errors' tab, address the primary cause of the issue and resume the task.
|
297
|
-
<br />
|
295
|
+
The recommended approach is to investigate the error messages below and in 'errors' tab, address the primary cause of the issue and resume the task.
|
298
296
|
</p>
|
299
297
|
</Col>
|
300
298
|
</Row>
|
@@ -18,6 +18,11 @@ export const TASKS_DASHBOARD_AVAILABLE_QUERY_MODES = {
|
|
18
18
|
OLDER: 'older',
|
19
19
|
};
|
20
20
|
|
21
|
+
export const TASKS_DASHBOARD_JS_QUERY_MODES = {
|
22
|
+
RECENT: 'recent',
|
23
|
+
OLDER: 'older',
|
24
|
+
};
|
25
|
+
|
21
26
|
export const TASKS_DASHBOARD_AVAILABLE_TIMES = {
|
22
27
|
H24: 'H24',
|
23
28
|
H12: 'H12',
|
@@ -4,6 +4,7 @@ import {
|
|
4
4
|
TASKS_DASHBOARD_AVAILABLE_TIMES,
|
5
5
|
TASKS_DASHBOARD_QUERY_KEYS_TEXT,
|
6
6
|
TASKS_DASHBOARD_QUERY_VALUES_TEXT,
|
7
|
+
TASKS_DASHBOARD_JS_QUERY_MODES,
|
7
8
|
} from './TasksDashboardConstants';
|
8
9
|
import { updateURlQuery } from '../TasksTable/TasksTableHelpers';
|
9
10
|
|
@@ -39,8 +40,8 @@ const queryFromUriQuery = uriQuery => {
|
|
39
40
|
if (uriQuery[uriField]) query[queryField] = uriQuery[uriField];
|
40
41
|
});
|
41
42
|
|
42
|
-
if (query.mode ===
|
43
|
-
query.mode =
|
43
|
+
if (query.mode === TASKS_DASHBOARD_JS_QUERY_MODES.RECENT) {
|
44
|
+
query.mode = TASKS_DASHBOARD_QUERY_VALUES_TEXT.LAST;
|
44
45
|
}
|
45
46
|
|
46
47
|
return query;
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import PropTypes from 'prop-types';
|
3
|
+
import { Alert, Button } from 'patternfly-react';
|
4
|
+
import { sprintf, translate as __ } from 'foremanReact/common/I18n';
|
5
|
+
|
6
|
+
export const SelectAllAlert = ({
|
7
|
+
itemCount,
|
8
|
+
perPage,
|
9
|
+
selectAllRows,
|
10
|
+
unselectAllRows,
|
11
|
+
allRowsSelected,
|
12
|
+
}) => {
|
13
|
+
const selectAllText = (
|
14
|
+
<React.Fragment>
|
15
|
+
{sprintf(
|
16
|
+
'All %s tasks on this page are selected',
|
17
|
+
Math.min(itemCount, perPage)
|
18
|
+
)}
|
19
|
+
<Button bsStyle="link" onClick={selectAllRows}>
|
20
|
+
{__('Select All')}
|
21
|
+
<b> {itemCount} </b> {__('tasks.')}
|
22
|
+
</Button>
|
23
|
+
</React.Fragment>
|
24
|
+
);
|
25
|
+
const undoSelectText = (
|
26
|
+
<React.Fragment>
|
27
|
+
{sprintf(__(`All %s tasks are selected. `), itemCount)}
|
28
|
+
<Button bsStyle="link" onClick={unselectAllRows}>
|
29
|
+
{__('Undo selection')}
|
30
|
+
</Button>
|
31
|
+
</React.Fragment>
|
32
|
+
);
|
33
|
+
const selectAlertText = allRowsSelected ? undoSelectText : selectAllText;
|
34
|
+
return <Alert type="info">{selectAlertText}</Alert>;
|
35
|
+
};
|
36
|
+
|
37
|
+
SelectAllAlert.propTypes = {
|
38
|
+
allRowsSelected: PropTypes.bool.isRequired,
|
39
|
+
itemCount: PropTypes.number.isRequired,
|
40
|
+
perPage: PropTypes.number.isRequired,
|
41
|
+
selectAllRows: PropTypes.func.isRequired,
|
42
|
+
unselectAllRows: PropTypes.func.isRequired,
|
43
|
+
};
|
@@ -0,0 +1,29 @@
|
|
1
|
+
import { testComponentSnapshotsWithFixtures } from '@theforeman/test';
|
2
|
+
|
3
|
+
import { SelectAllAlert } from '../SelectAllAlert';
|
4
|
+
|
5
|
+
const baseProps = {
|
6
|
+
itemCount: 7,
|
7
|
+
perPage: 5,
|
8
|
+
selectAllRows: jest.fn(),
|
9
|
+
unselectAllRows: jest.fn(),
|
10
|
+
};
|
11
|
+
const fixtures = {
|
12
|
+
'renders SelectAllAlert with perPage > itemCout': {
|
13
|
+
allRowsSelected: false,
|
14
|
+
itemCount: 7,
|
15
|
+
perPage: 10,
|
16
|
+
...baseProps,
|
17
|
+
},
|
18
|
+
'renders SelectAllAlert without all rows selected': {
|
19
|
+
allRowsSelected: false,
|
20
|
+
...baseProps,
|
21
|
+
},
|
22
|
+
'renders SelectAllAlert with all rows selected': {
|
23
|
+
allRowsSelected: true,
|
24
|
+
...baseProps,
|
25
|
+
},
|
26
|
+
};
|
27
|
+
|
28
|
+
describe('SelectAllAlert', () =>
|
29
|
+
testComponentSnapshotsWithFixtures(SelectAllAlert, fixtures));
|
@@ -0,0 +1,75 @@
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
2
|
+
|
3
|
+
exports[`SelectAllAlert renders SelectAllAlert with all rows selected 1`] = `
|
4
|
+
<Alert
|
5
|
+
className=""
|
6
|
+
onDismiss={null}
|
7
|
+
type="info"
|
8
|
+
>
|
9
|
+
All 7 tasks are selected.
|
10
|
+
<Button
|
11
|
+
active={false}
|
12
|
+
block={false}
|
13
|
+
bsClass="btn"
|
14
|
+
bsStyle="link"
|
15
|
+
disabled={false}
|
16
|
+
onClick={[MockFunction]}
|
17
|
+
>
|
18
|
+
Undo selection
|
19
|
+
</Button>
|
20
|
+
</Alert>
|
21
|
+
`;
|
22
|
+
|
23
|
+
exports[`SelectAllAlert renders SelectAllAlert with perPage > itemCout 1`] = `
|
24
|
+
<Alert
|
25
|
+
className=""
|
26
|
+
onDismiss={null}
|
27
|
+
type="info"
|
28
|
+
>
|
29
|
+
All 5 tasks on this page are selected
|
30
|
+
<Button
|
31
|
+
active={false}
|
32
|
+
block={false}
|
33
|
+
bsClass="btn"
|
34
|
+
bsStyle="link"
|
35
|
+
disabled={false}
|
36
|
+
onClick={[MockFunction]}
|
37
|
+
>
|
38
|
+
Select All
|
39
|
+
<b>
|
40
|
+
|
41
|
+
7
|
42
|
+
|
43
|
+
</b>
|
44
|
+
|
45
|
+
tasks.
|
46
|
+
</Button>
|
47
|
+
</Alert>
|
48
|
+
`;
|
49
|
+
|
50
|
+
exports[`SelectAllAlert renders SelectAllAlert without all rows selected 1`] = `
|
51
|
+
<Alert
|
52
|
+
className=""
|
53
|
+
onDismiss={null}
|
54
|
+
type="info"
|
55
|
+
>
|
56
|
+
All 5 tasks on this page are selected
|
57
|
+
<Button
|
58
|
+
active={false}
|
59
|
+
block={false}
|
60
|
+
bsClass="btn"
|
61
|
+
bsStyle="link"
|
62
|
+
disabled={false}
|
63
|
+
onClick={[MockFunction]}
|
64
|
+
>
|
65
|
+
Select All
|
66
|
+
<b>
|
67
|
+
|
68
|
+
7
|
69
|
+
|
70
|
+
</b>
|
71
|
+
|
72
|
+
tasks.
|
73
|
+
</Button>
|
74
|
+
</Alert>
|
75
|
+
`;
|
@@ -15,7 +15,8 @@ export const SubTasksPage = props => {
|
|
15
15
|
{ caption: __('Sub tasks') },
|
16
16
|
],
|
17
17
|
});
|
18
|
-
const createHeader = actionName =>
|
18
|
+
const createHeader = actionName =>
|
19
|
+
actionName ? sprintf(__('Sub tasks of %s'), actionName) : __('Sub tasks');
|
19
20
|
return (
|
20
21
|
<TasksTablePage
|
21
22
|
getBreadcrumbs={getBreadcrumbs}
|