foreman_rh_cloud 14.2.1 → 14.4.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/models/concerns/rh_cloud_host.rb +36 -1
  3. data/config/routes.rb +1 -0
  4. data/lib/foreman_inventory_upload/async/upload_report_direct_job.rb +2 -0
  5. data/lib/foreman_rh_cloud/version.rb +1 -1
  6. data/package.json +1 -1
  7. data/test/jobs/upload_report_direct_job_test.rb +29 -0
  8. data/test/unit/insights_facet_test.rb +5 -0
  9. data/test/unit/rh_cloud_host_test.rb +68 -0
  10. data/webpack/ForemanInventoryUpload/Components/AccountList/AccountList.js +5 -1
  11. data/webpack/ForemanInventoryUpload/Components/AccountList/AccountListConstants.js +5 -0
  12. data/webpack/ForemanInventoryUpload/Components/InventorySettings/MinimalInventoryDropdown.js +1 -0
  13. data/webpack/ForemanInventoryUpload/Components/PageHeader/PageTitle.js +1 -0
  14. data/webpack/ForemanInventoryUpload/Components/TaskConstants.js +6 -0
  15. data/webpack/ForemanInventoryUpload/Components/TaskHistory/TaskHistory.js +3 -2
  16. data/webpack/ForemanInventoryUpload/Components/TaskProgress/TaskProgress.js +4 -3
  17. data/webpack/ForemanInventoryUpload/ForemanInventoryHelpers.js +1 -1
  18. data/webpack/ForemanInventoryUpload/__tests__/ForemanInventoryHelpers.test.js +3 -3
  19. data/webpack/ForemanRhCloudPages.js +6 -0
  20. data/webpack/InsightsCloudSync/Components/InsightsTable/InsightsLabel.js +8 -7
  21. data/webpack/InsightsCloudSync/Components/InsightsTable/InsightsTableConstants.js +2 -2
  22. data/webpack/InsightsCloudSync/Components/InsightsTable/Pagination.js +1 -0
  23. data/webpack/InsightsCloudSync/Components/RemediationModal/RemediationModalFooter.js +5 -2
  24. data/webpack/InsightsCloudSync/Components/RemediationModal/RemediationTableConstants.js +4 -0
  25. data/webpack/InsightsCloudSync/Components/ToolbarDropdown.js +1 -0
  26. data/webpack/InsightsHostDetailsTab/InsightsTab.js +53 -39
  27. data/webpack/InsightsHostDetailsTab/InsightsTab.scss +7 -73
  28. data/webpack/InsightsHostDetailsTab/__tests__/InsightsTab.fixtures.js +7 -0
  29. data/webpack/InsightsHostDetailsTab/__tests__/InsightsTab.test.js +52 -30
  30. data/webpack/InsightsHostDetailsTab/components/ListItem/ListItem.js +43 -35
  31. data/webpack/InsightsHostDetailsTab/components/ListItem/__tests__/ListItem.test.js +111 -0
  32. data/webpack/IopPathwayDetails/IopPathwayDetails.js +40 -0
  33. data/webpack/IopRecommendationDetails/IopRecommendationDetails.js +1 -0
  34. data/webpack/__mocks__/foremanReact/redux/middlewares/IntervalMiddleware.js +5 -1
  35. data/webpack/common/DropdownToggle.js +1 -0
  36. data/webpack/common/Switcher/HelpLabel.js +7 -5
  37. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89dec7188d18373800314f614305ccece5e477bb6d1db9ce24c35c288b101ee2
4
- data.tar.gz: 4d7fe9c4aff407cbf2945a45f4d2c032f7a2d5672740c890e3691287c3cd35c6
3
+ metadata.gz: '03458c064241e69e2f5384094f124fd0911a9bb2b08a0e299581c8d5c22ef5da'
4
+ data.tar.gz: 4a14089d8e6410a5389473824d6206637370a34c552dcd178fe843b2eea3307c
5
5
  SHA512:
6
- metadata.gz: 6e2daa770c20d244f2b86f81cc447661de17c1b7ead87cf1989bf1a5fe50858601a1f847c4fdaff88501313da45cb0d826d169906cd4682f32ebb8b0c6e3e5f4
7
- data.tar.gz: 830c5dc063d390c5b76d8e637b89eebbafc77add2d2245f71a5372b292c324057c5ba0c3394f4ae436c2a718979c352a405b127b8f38e578513d3460b1c1fffa
6
+ metadata.gz: c5daadeadfe2543a242aea723e832b4fcc5f7ac31a598fc05f34792e1c58042ec993718e07b96bff3841b35f366438b8ed7e52166fe94ea118f635f9caadfa16
7
+ data.tar.gz: c6008984ca0d88a5bcc51a1d6fd288e031b51238a0c561fcdb414cc967b7814750a529c5f5c0916866401dd922b71efd9fc20ad0b927fb2faec36ee21d5ae859
@@ -10,7 +10,8 @@ module RhCloudHost
10
10
  )
11
11
 
12
12
  has_many :insights_hits, through: :insights, source: :hits
13
- scoped_search :relation => :insights, :on => :hits_count, :only_explicit => true, :rename => :insights_recommendations_count
13
+ scoped_search :on => :id, :rename => :insights_recommendations_count, :only_explicit => true,
14
+ :ext_method => :search_by_insights_recommendations_count, :complete_value => false
14
15
 
15
16
  has_one :insights_client_report_status_object, :class_name => '::InsightsClientReportStatus', :foreign_key => 'host_id'
16
17
  scoped_search :relation => :insights_client_report_status_object, :on => :status, :rename => :insights_client_report_status,
@@ -46,6 +47,40 @@ module RhCloudHost
46
47
  end
47
48
 
48
49
  module ClassMethods
50
+ def search_by_insights_recommendations_count(_key, operator, value)
51
+ if ForemanRhCloud.with_iop_smart_proxy?
52
+ raise ScopedSearch::QueryNotSupported.new(
53
+ _('Searching by recommendations count is not available in IoP mode.')
54
+ )
55
+ end
56
+
57
+ unless ScopedSearch::QueryBuilder::SQL_OPERATORS.value?(operator)
58
+ raise ScopedSearch::QueryNotSupported.new(
59
+ _('Unsupported operator for recommendations count search: %s') % operator
60
+ )
61
+ end
62
+
63
+ facet_table = InsightsFacet.table_name
64
+ hits_count_col = "COALESCE(#{facet_table}.hits_count, 0)"
65
+
66
+ if ['IN', 'NOT IN'].include?(operator)
67
+ values = value.is_a?(Array) ? value : value.to_s.split(',').map(&:strip)
68
+ placeholders = (['?'] * values.size).join(',')
69
+ condition = sanitize_sql_for_conditions(
70
+ ["#{hits_count_col} #{operator} (#{placeholders})", *values]
71
+ )
72
+ else
73
+ condition = sanitize_sql_for_conditions(
74
+ ["#{hits_count_col} #{operator} ?", value_to_sql(operator, value)]
75
+ )
76
+ end
77
+
78
+ {
79
+ joins: "LEFT JOIN #{facet_table} ON #{facet_table}.host_id = #{Host::Managed.table_name}.id",
80
+ conditions: condition,
81
+ }
82
+ end
83
+
49
84
  def search_by_insights_uuid(_key, operator, value)
50
85
  # Determine which facet table to search based on IoP mode
51
86
  facet_table = ForemanRhCloud.with_iop_smart_proxy? ? Katello::Host::SubscriptionFacet.table_name : InsightsFacet.table_name
data/config/routes.rb CHANGED
@@ -33,6 +33,7 @@ Rails.application.routes.draw do
33
33
  namespace :foreman_rh_cloud do
34
34
  get 'inventory_upload', to: 'foreman_rh_cloud#inventory_upload'
35
35
  get 'recommendations', to: 'foreman_rh_cloud#recommendations'
36
+ get 'recommendations/pathways/:slug', to: 'foreman_rh_cloud#recommendations'
36
37
  get 'recommendations/:rule_id', to: 'foreman_rh_cloud#recommendations'
37
38
  get 'insights_cloud', to: '/react#index' # Uses foreman's react controller
38
39
  get 'insights_vulnerability', to: '/react#index'
@@ -59,11 +59,13 @@ module ForemanInventoryUpload
59
59
  def try_execute
60
60
  if content_disconnected?
61
61
  logger.info("Upload canceled: connection to Insights is not enabled. Report location: #{filename}")
62
+ done!
62
63
  return
63
64
  end
64
65
 
65
66
  unless organization.owner_details&.dig('upstreamConsumer', 'idCert')
66
67
  logger.info("Skipping organization '#{organization}', no candlepin certificate defined.")
68
+ done!
67
69
  return
68
70
  end
69
71
 
@@ -1,3 +1,3 @@
1
1
  module ForemanRhCloud
2
- VERSION = '14.2.1'.freeze
2
+ VERSION = '14.4.0'.freeze
3
3
  end
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foreman_rh_cloud",
3
- "version": "14.2.1",
3
+ "version": "14.4.0",
4
4
  "description": "Inventory Upload =============",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -401,4 +401,33 @@ class UploadReportDirectJobTest < ActiveSupport::TestCase
401
401
  file.close
402
402
  end
403
403
  end
404
+
405
+ test 'mark as done when upload aborted due to missing certificate' do
406
+ # Remove certificate from organization
407
+ Organization.any_instance.stubs(:owner_details).returns({})
408
+
409
+ action = create_action(ForemanInventoryUpload::Async::UploadReportDirectJob)
410
+ action.expects(:action_subject).with(@organization)
411
+ plan_action(action, nil, @organization.id)
412
+
413
+ # Execute the action
414
+ action.send(:try_execute)
415
+
416
+ # Verify it is marked as done
417
+ assert action.done?
418
+ end
419
+
420
+ test 'mark as done when upload aborted due to disconnected mode' do
421
+ Setting.stubs(:[]).with(:subscription_connection_enabled).returns(false)
422
+
423
+ action = create_action(ForemanInventoryUpload::Async::UploadReportDirectJob)
424
+ action.expects(:action_subject).with(@organization)
425
+ plan_action(action, nil, @organization.id)
426
+
427
+ # Execute the action
428
+ action.send(:try_execute)
429
+
430
+ # Verify it is marked as done
431
+ assert action.done?
432
+ end
404
433
  end
@@ -6,6 +6,10 @@ class InsightsFacetTest < ActiveSupport::TestCase
6
6
  InsightsFacet.reset_counters(@host.insights.id, :hits_count)
7
7
  end
8
8
 
9
+ teardown do
10
+ ForemanRhCloud.unstub(:with_iop_smart_proxy?)
11
+ end
12
+
9
13
  test 'host with hits can be deleted' do
10
14
  assert_equal 1, @host.insights.hits.count
11
15
 
@@ -16,6 +20,7 @@ class InsightsFacetTest < ActiveSupport::TestCase
16
20
  end
17
21
 
18
22
  test 'search host by recommendations_count' do
23
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false)
19
24
  FactoryBot.create(:host) # create another host with no recommendations
20
25
 
21
26
  assert_equal 1, Host.search_for('insights_recommendations_count = 1').count
@@ -343,6 +343,74 @@ class RhCloudHostTest < ActiveSupport::TestCase
343
343
  end
344
344
  end
345
345
 
346
+ context 'scoped search on insights_recommendations_count' do
347
+ setup do
348
+ @org = FactoryBot.create(:organization)
349
+ end
350
+
351
+ teardown do
352
+ ForemanRhCloud.unstub(:with_iop_smart_proxy?)
353
+ end
354
+
355
+ test 'searches insights_facets.hits_count in non-IoP mode' do
356
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false)
357
+ host_with_hits = FactoryBot.create(:host, :with_insights_hits, organization: @org)
358
+ InsightsFacet.reset_counters(host_with_hits.insights.id, :hits_count)
359
+ host_without_hits = FactoryBot.create(:host, :managed, organization: @org)
360
+
361
+ results = Host::Managed.search_for('insights_recommendations_count = 1')
362
+
363
+ assert_includes results, host_with_hits
364
+ assert_not_includes results, host_without_hits
365
+ end
366
+
367
+ test 'supports greater-than operator in non-IoP mode' do
368
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false)
369
+ host_with_hits = FactoryBot.create(:host, :with_insights_hits, organization: @org)
370
+ InsightsFacet.reset_counters(host_with_hits.insights.id, :hits_count)
371
+ host_without_hits = FactoryBot.create(:host, :managed, organization: @org)
372
+
373
+ results = Host::Managed.search_for('insights_recommendations_count > 0')
374
+
375
+ assert_includes results, host_with_hits
376
+ assert_not_includes results, host_without_hits
377
+ end
378
+
379
+ test 'raises QueryNotSupported in IoP mode' do
380
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(true)
381
+
382
+ error = assert_raises(ScopedSearch::QueryNotSupported) do
383
+ Host::Managed.search_for('insights_recommendations_count = 1')
384
+ end
385
+
386
+ assert_match(/not available in IoP mode/, error.message)
387
+ end
388
+
389
+ test 'supports IN operator in non-IoP mode' do
390
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false)
391
+ host_with_hits = FactoryBot.create(:host, :with_insights_hits, organization: @org)
392
+ InsightsFacet.reset_counters(host_with_hits.insights.id, :hits_count)
393
+ host_without_hits = FactoryBot.create(:host, :managed, organization: @org)
394
+
395
+ results = Host::Managed.search_for('insights_recommendations_count ^ (1,2,3)')
396
+
397
+ assert_includes results, host_with_hits
398
+ assert_not_includes results, host_without_hits
399
+ end
400
+
401
+ test 'handles hosts without insights facets in non-IoP mode' do
402
+ ForemanRhCloud.stubs(:with_iop_smart_proxy?).returns(false)
403
+ host_without_facet = FactoryBot.create(:host, :managed, organization: @org)
404
+ host_with_facet = FactoryBot.create(:host, :with_insights_hits, organization: @org)
405
+ InsightsFacet.reset_counters(host_with_facet.insights.id, :hits_count)
406
+
407
+ results = Host::Managed.search_for('insights_recommendations_count = 1')
408
+
409
+ assert_includes results, host_with_facet
410
+ assert_not_includes results, host_without_facet
411
+ end
412
+ end
413
+
346
414
  test 'scoped search for user_omitted inventory status works' do
347
415
  host1 = FactoryBot.create(:host, :managed)
348
416
  host2 = FactoryBot.create(:host, :managed)
@@ -8,6 +8,7 @@ import EmptyState from './Components/EmptyState';
8
8
  import ErrorState from './Components/ErrorState';
9
9
  import EmptyResults from './Components/EmptyResults';
10
10
  import { filterAccounts } from './AccountListHelper';
11
+ import { ACCOUNT_STATUS_POLLING_INTERVAL_MS } from './AccountListConstants';
11
12
  import './accountList.scss';
12
13
 
13
14
  const AccountList = ({
@@ -21,7 +22,10 @@ const AccountList = ({
21
22
  }) => {
22
23
  useEffect(() => {
23
24
  fetchAccountsStatus();
24
- const pollingID = setInterval(fetchAccountsStatus, 2000);
25
+ const pollingID = setInterval(
26
+ fetchAccountsStatus,
27
+ ACCOUNT_STATUS_POLLING_INTERVAL_MS
28
+ );
25
29
  startAccountStatusPolling(pollingID);
26
30
 
27
31
  return () => {
@@ -7,3 +7,8 @@ export const INVENTORY_ACCOUNT_STATUS_POLLING_START =
7
7
  export const INVENTORY_ACCOUNT_STATUS_POLLING_STOP =
8
8
  'INVENTORY_ACCOUNT_STATUS_POLLING_STOP';
9
9
  export const INVENTORY_PROCESS_RESTART = 'INVENTORY_PROCESS_RESTART';
10
+
11
+ // How often to re-fetch account statuses while generation/upload is in
12
+ // progress. Frequent enough to reflect status changes promptly, without
13
+ // hammering the status endpoint.
14
+ export const ACCOUNT_STATUS_POLLING_INTERVAL_MS = 2000;
@@ -79,6 +79,7 @@ const MinimalInventoryDropdown = ({ setChosenValue }) => {
79
79
  isFullWidth
80
80
  onClick={onToggleClick}
81
81
  isExpanded={isOpen}
82
+ ouiaId="inventory-dropdown-toggle"
82
83
  >
83
84
  {dropdownValues[currentDropdownValue].title}
84
85
  </MenuToggle>
@@ -59,6 +59,7 @@ const PageTitle = () => {
59
59
  variant="plain"
60
60
  onClick={() => setIsDropdownOpen(prev => !prev)}
61
61
  isExpanded={isDropdownOpen}
62
+ ouiaId="title-dropdown-toggle"
62
63
  >
63
64
  <EllipsisVIcon />
64
65
  </MenuToggle>
@@ -0,0 +1,6 @@
1
+ // Task durations reported by the API are in seconds; used to derive
2
+ // minutes/seconds for display in TaskHistory and TaskProgress.
3
+ export const SECONDS_PER_MINUTE = 60;
4
+
5
+ // Progress percentage to show once a task has finished running.
6
+ export const PERCENT_COMPLETE = 100;
@@ -18,6 +18,7 @@ import {
18
18
  } from '@patternfly/react-icons';
19
19
  import { translate as __ } from 'foremanReact/common/I18n';
20
20
  import RelativeDateTime from 'foremanReact/components/common/dates/RelativeDateTime';
21
+ import { SECONDS_PER_MINUTE } from '../TaskConstants';
21
22
  import './taskHistory.scss';
22
23
 
23
24
  const TaskHistory = ({ tasks, title }) => {
@@ -52,8 +53,8 @@ const TaskHistory = ({ tasks, title }) => {
52
53
  if (!Number.isFinite(seconds) || seconds < 0) {
53
54
  return __('N/A');
54
55
  }
55
- const mins = Math.floor(seconds / 60);
56
- const secs = Math.floor(seconds % 60);
56
+ const mins = Math.floor(seconds / SECONDS_PER_MINUTE);
57
+ const secs = Math.floor(seconds % SECONDS_PER_MINUTE);
57
58
  if (mins > 0) {
58
59
  return `${mins}m ${secs}s`;
59
60
  }
@@ -29,6 +29,7 @@ import { useDispatch, useSelector } from 'react-redux';
29
29
  import { inventoryUrl } from '../../ForemanInventoryHelpers';
30
30
  import { selectSubscriptionConnectionEnabled } from '../InventorySettings/InventorySettingsSelectors';
31
31
  import { useIopConfig } from '../../../common/Hooks/ConfigHooks';
32
+ import { SECONDS_PER_MINUTE, PERCENT_COMPLETE } from '../TaskConstants';
32
33
  import './taskProgress.scss';
33
34
 
34
35
  const TaskProgress = ({
@@ -213,8 +214,8 @@ const TaskProgress = ({
213
214
  ) {
214
215
  return '';
215
216
  }
216
- const mins = Math.floor(seconds / 60);
217
- const secs = Math.floor(seconds % 60);
217
+ const mins = Math.floor(seconds / SECONDS_PER_MINUTE);
218
+ const secs = Math.floor(seconds % SECONDS_PER_MINUTE);
218
219
  if (mins > 0) {
219
220
  return `${mins}m ${secs}s`;
220
221
  }
@@ -232,7 +233,7 @@ const TaskProgress = ({
232
233
  if (isStartingNewTask) {
233
234
  progressValue = 0;
234
235
  } else if (task.state === 'stopped') {
235
- progressValue = 100;
236
+ progressValue = PERCENT_COMPLETE;
236
237
  } else {
237
238
  progressValue = task.progress || 0;
238
239
  }
@@ -7,7 +7,7 @@ export const inventoryUrl = path =>
7
7
 
8
8
  export const getInventoryDocsUrl = () =>
9
9
  getDocsURL(
10
- 'Managing_Hosts',
10
+ 'Administering_Project',
11
11
  'configuring-foreman-server-for-cloud-connection'
12
12
  );
13
13
 
@@ -10,11 +10,11 @@ jest.mock('foremanReact/common/helpers', () => ({
10
10
 
11
11
  describe('ForemanInventoryUpload helpers', () => {
12
12
  describe('getInventoryDocsUrl', () => {
13
- it('requests the Managing Hosts guide at the cloud connection chapter', () => {
13
+ it('requests the admin guide cloud connection chapter', () => {
14
14
  getInventoryDocsUrl();
15
15
  expect(getDocsURL).toHaveBeenCalledWith(
16
- 'Managing_Hosts',
17
- 'configuring-foreman-server-for-cloud-connection'
16
+ 'Administering_Project',
17
+ 'configuring-foreman-server-for-cloud-connection'
18
18
  );
19
19
  });
20
20
  });
@@ -5,6 +5,7 @@ import ForemanInventoryUpload from './ForemanInventoryUpload';
5
5
  import InsightsVulnerabilityListPage from './InsightsVulnerability/InsightsVulnerabilityListPage';
6
6
  import InsightsCloudSync from './InsightsCloudSync';
7
7
  import IopRecommendationDetails from './IopRecommendationDetails/IopRecommendationDetails';
8
+ import IopPathwayDetails from './IopPathwayDetails/IopPathwayDetails';
8
9
  import InsightsHostDetailsTab from './InsightsHostDetailsTab';
9
10
  import CveDetailsPage from './CveDetailsPage';
10
11
  import './common/styles.scss';
@@ -31,6 +32,11 @@ export const routes = [
31
32
  exact: true,
32
33
  render: props => <InsightsCloudSync {...props} />,
33
34
  },
35
+ {
36
+ path: '/foreman_rh_cloud/recommendations/pathways/:slug',
37
+ exact: false,
38
+ render: props => <IopPathwayDetails {...props} />,
39
+ },
34
40
  {
35
41
  path: '/foreman_rh_cloud/recommendations',
36
42
  exact: false,
@@ -7,21 +7,22 @@ import {
7
7
  } from '@patternfly/react-icons';
8
8
  import { Label } from '@patternfly/react-core';
9
9
  import PropTypes from 'prop-types';
10
+ import { translate as __ } from 'foremanReact/common/I18n';
10
11
 
11
12
  const VALUE_TO_STATE = {
12
- 1: { icon: <AngleDoubleDownIcon />, text: 'Low', color: 'blue' },
13
- 2: { icon: <EqualsIcon />, text: 'Moderate', color: 'yellow' },
14
- 3: { icon: <AngleDoubleUpIcon />, text: 'Important', color: 'orange' },
15
- 4: { icon: <CriticalRiskIcon />, text: 'Critical', color: 'red' },
13
+ 1: { icon: <AngleDoubleDownIcon />, text: __('Low'), color: 'blue' },
14
+ 2: { icon: <EqualsIcon />, text: __('Moderate'), color: 'yellow' },
15
+ 3: { icon: <AngleDoubleUpIcon />, text: __('Important'), color: 'orange' },
16
+ 4: { icon: <CriticalRiskIcon />, text: __('Critical'), color: 'red' },
16
17
  };
17
18
 
18
19
  const InsightsLabel = ({ value = 1, text, hideIcon, ...props }) => (
19
20
  <Label
20
21
  {...props}
21
- color={VALUE_TO_STATE[value].color}
22
- icon={!hideIcon && VALUE_TO_STATE[value].icon}
22
+ color={VALUE_TO_STATE[value]?.color}
23
+ icon={!hideIcon && VALUE_TO_STATE[value]?.icon}
23
24
  >
24
- {text || VALUE_TO_STATE[value].text}
25
+ {text || VALUE_TO_STATE[value]?.text}
25
26
  </Label>
26
27
  );
27
28
 
@@ -31,7 +31,7 @@ export const actionsFormatter = rowData => {
31
31
  recommendationUrl &&
32
32
  !isLocalAdvisorEngine &&
33
33
  dropdownItems.push(
34
- <DropdownItem key="recommendation-url">
34
+ <DropdownItem key="recommendation-url" ouiaId="insights-dropdown-item-recommendation-url">
35
35
  <a href={recommendationUrl} target="_blank" rel="noopener noreferrer">
36
36
  {__('View in Red Hat Insights')} <ExternalLinkAltIcon />
37
37
  </a>
@@ -40,7 +40,7 @@ export const actionsFormatter = rowData => {
40
40
 
41
41
  accessRHUrl &&
42
42
  dropdownItems.push(
43
- <DropdownItem key="access-url">
43
+ <DropdownItem key="access-url" ouiaId="insights-dropdown-item-knowledgebase">
44
44
  <a href={accessRHUrl} target="_blank" rel="noopener noreferrer">
45
45
  {__('Knowledgebase article')} <ExternalLinkAltIcon />
46
46
  </a>
@@ -42,6 +42,7 @@ const Pagination = ({ variant, ...props }) => {
42
42
 
43
43
  return (
44
44
  <PfPagination
45
+ ouiaId={`recommendations-pagination-${variant}`}
45
46
  itemCount={itemCount}
46
47
  widgetId={`recommendation-pagination-${variant}`}
47
48
  perPage={perPage}
@@ -3,7 +3,10 @@ import PropTypes from 'prop-types';
3
3
  import { Button } from '@patternfly/react-core';
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
5
  import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
6
- import { JOB_INVOCATION_PATH } from './RemediationTableConstants';
6
+ import {
7
+ JOB_INVOCATION_PATH,
8
+ FORM_SUBMIT_DELAY_MS,
9
+ } from './RemediationTableConstants';
7
10
 
8
11
  const ModalFooter = ({ toggleModal, resolutions, hostsIds, isIop }) => {
9
12
  let token = document.querySelector('meta[name="csrf-token"]');
@@ -24,7 +27,7 @@ const ModalFooter = ({ toggleModal, resolutions, hostsIds, isIop }) => {
24
27
  setTimeout(() => {
25
28
  // eslint-disable-next-line no-unused-expressions
26
29
  formRef.current?.submit?.();
27
- }, 100);
30
+ }, FORM_SUBMIT_DELAY_MS);
28
31
  };
29
32
  return (
30
33
  <form
@@ -45,3 +45,7 @@ export const JOB_INVOCATION_PATH = foremanUrl('/job_invocations/new');
45
45
  export const REMEDIATIONS_API_KEY = 'INSIGHTS_REMEDIATIONS';
46
46
 
47
47
  export const SUBMIT_RESOLUTIONS = 'SUBMIT_INSIGHTS_RESOLUTIONS';
48
+
49
+ // Brief delay before the (non-React) form submit/navigation fires, so the
50
+ // button's isLoading spinner has a chance to render first.
51
+ export const FORM_SUBMIT_DELAY_MS = 100;
@@ -53,6 +53,7 @@ const ToolbarDropdown = ({ onRecommendationSync }) => {
53
53
  aria-label={__('Recommendations actions')}
54
54
  onClick={() => setIsDropdownOpen(prev => !prev)}
55
55
  isExpanded={isDropdownOpen}
56
+ ouiaId="recommendations-dropdown-toggle"
56
57
  >
57
58
  <EllipsisVIcon />
58
59
  </MenuToggle>
@@ -1,56 +1,70 @@
1
- import React from 'react';
1
+ import React, { useEffect } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { orderBy } from 'lodash';
4
- import { Grid, ListView } from 'patternfly-react';
4
+ import { Accordion, Grid, GridItem, Title } from '@patternfly/react-core';
5
5
  import { noop } from 'foremanReact/common/helpers';
6
6
  import { translate as __ } from 'foremanReact/common/I18n';
7
7
  import ListItem from './components/ListItem';
8
8
  import './InsightsTab.scss';
9
9
 
10
- class InsightsHostDetailsTab extends React.Component {
11
- componentDidMount() {
12
- const { fetchHits, hostID } = this.props;
10
+ const InsightsHostDetailsTab = ({ fetchHits, hostID, hits }) => {
11
+ useEffect(() => {
13
12
  fetchHits(hostID);
14
- }
15
-
16
- render() {
17
- const { hits } = this.props;
13
+ }, [hostID]);
18
14
 
19
- if (!hits.length) {
20
- return <h2>{__('No recommendations were found for this host!')}</h2>;
21
- }
22
- const hitsSorted = orderBy(hits, ['total_risk'], ['desc']);
23
- const items = hitsSorted.map(
24
- (
25
- {
26
- title,
27
- total_risk: totalRisk,
28
- results_url: resultsUrl,
29
- solution_url: solutionUrl,
30
- },
31
- index
32
- ) => (
33
- <ListItem
34
- key={index}
35
- title={title}
36
- totalRisk={totalRisk}
37
- resultsUrl={resultsUrl}
38
- solutionUrl={solutionUrl}
39
- />
40
- )
41
- );
15
+ if (!hits.length) {
42
16
  return (
43
17
  <div id="host_details_insights_tab">
44
- <Grid.Row>
45
- <Grid.Col xs={12}>
46
- <h2>{__('Recommendations')}</h2>
47
- <ListView id="hits_list">{items}</ListView>
48
- </Grid.Col>
49
- </Grid.Row>
18
+ <Title
19
+ headingLevel="h2"
20
+ size="md"
21
+ ouiaId="insights-recommendations-empty-title"
22
+ >
23
+ {__('No recommendations were found for this host!')}
24
+ </Title>
50
25
  </div>
51
26
  );
52
27
  }
53
- }
28
+
29
+ const hitsSorted = orderBy(hits, ['total_risk'], ['desc']);
30
+
31
+ return (
32
+ <div id="host_details_insights_tab">
33
+ <Grid hasGutter>
34
+ <GridItem span={12}>
35
+ <Title
36
+ headingLevel="h2"
37
+ size="md"
38
+ ouiaId="insights-recommendations-title"
39
+ >
40
+ {__('Recommendations')}
41
+ </Title>
42
+ <div id="hits_list" data-testid="hits-list">
43
+ <Accordion>
44
+ {hitsSorted.map(
45
+ ({
46
+ id,
47
+ title,
48
+ total_risk: totalRisk,
49
+ results_url: resultsUrl,
50
+ solution_url: solutionUrl,
51
+ }) => (
52
+ <ListItem
53
+ key={id}
54
+ title={title}
55
+ totalRisk={totalRisk}
56
+ resultsUrl={resultsUrl}
57
+ solutionUrl={solutionUrl}
58
+ />
59
+ )
60
+ )}
61
+ </Accordion>
62
+ </div>
63
+ </GridItem>
64
+ </Grid>
65
+ </div>
66
+ );
67
+ };
54
68
 
55
69
  InsightsHostDetailsTab.propTypes = {
56
70
  hostID: PropTypes.number.isRequired,
@@ -1,84 +1,18 @@
1
1
  #host_details_insights_tab {
2
- #btn_toolbar {
3
- float: right;
4
- }
5
-
6
- #btn_fullscreen {
7
- margin-left: 5px;
8
-
9
- .fa-arrows-alt {
10
- margin-left: 5px;
11
- }
12
- }
13
-
14
- h2 {
15
- margin-top: 5px;
16
- }
17
-
18
2
  #hits_list {
19
3
  max-height: 650px;
20
4
  overflow-y: scroll;
21
5
  overflow-x: hidden;
22
- margin-top: 15px;
23
-
24
- .list-view-pf-expand {
25
- padding: 0;
26
- }
27
-
28
- .list-group-item-header {
29
- .list-view-pf-expand .fa-angle-right {
30
- margin-top: 6px;
31
- }
32
-
33
- .list-view-pf-main-info .list-view-pf-description {
34
- width: 83%;
35
-
36
- .list-group-item-heading {
37
- width: 375px;
38
- }
39
-
40
- p {
41
- margin: 0;
42
- font-size: 14px;
43
- }
44
- }
45
-
46
- .list-view-pf-additional-info {
47
- .risk-label {
48
- padding: 5px 8px;
49
- border-radius: 12px;
50
-
51
- &.risk-1 {
52
- background-color: #e7f1fa;
53
- }
54
-
55
- &.risk-2 {
56
- background-color: #fdf7e7;
57
- }
58
-
59
- &.risk-3 {
60
- background-color: #f9dddd;
61
- }
62
-
63
- &.risk-4 {
64
- background-color: #ffecec;
65
- }
66
-
67
- p {
68
- margin: 0;
69
- }
70
- }
71
- }
72
- }
6
+ margin-top: var(--pf-v5-global--spacer--md);
73
7
 
74
8
  &::-webkit-scrollbar {
75
- width: 12px;
76
- height: 12px;
9
+ width: var(--pf-v5-global--spacer--sm);
10
+ height: var(--pf-v5-global--spacer--sm);
77
11
  }
78
12
 
79
13
  &::-webkit-scrollbar-thumb {
80
- background: #222;
81
- border-radius: 6px;
14
+ background: var(--pf-v5-global--palette--black-900);
15
+ border-radius: var(--pf-v5-global--BorderRadius--sm);
82
16
  border: 3px solid transparent;
83
17
  background-clip: content-box;
84
18
  }
@@ -86,9 +20,9 @@
86
20
  }
87
21
 
88
22
  #new_host_details_insights_tab {
89
- padding: 24px;
23
+ padding: var(--pf-v5-global--spacer--lg);
90
24
  }
91
25
 
92
26
  #total-risks-dropdown-container ul.pf-v5-c-menu__list {
93
- margin-bottom: 0px;
27
+ margin-bottom: 0;
94
28
  }
@@ -2,6 +2,7 @@ export const hostID = 1234;
2
2
 
3
3
  export const hits = [
4
4
  {
5
+ id: 1,
5
6
  hostname: 'my-host.example.com',
6
7
  rhel_version: '7.8',
7
8
  uuid: '4739b323-a343-4e89-b71b-81991b8dc656',
@@ -17,6 +18,12 @@ export const hits = [
17
18
  },
18
19
  ];
19
20
 
21
+ export const multipleHits = [
22
+ { ...hits[0], id: 1, title: 'Low risk', total_risk: 1 },
23
+ { ...hits[0], id: 2, title: 'High risk', total_risk: 4 },
24
+ { ...hits[0], id: 3, title: 'Medium risk', total_risk: 2 },
25
+ ];
26
+
20
27
  export const props = {
21
28
  hostID,
22
29
  hits,
@@ -1,46 +1,68 @@
1
1
  import React from 'react';
2
- import { render, screen } from '@testing-library/react';
2
+ import { screen, waitFor, within } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import { rtlHelpers } from 'foremanReact/common/rtlTestHelpers';
3
5
  import InsightsTab from '../InsightsTab';
4
- import { hits, hostID } from './InsightsTab.fixtures';
6
+ import { hits, hostID, multipleHits } from './InsightsTab.fixtures';
7
+
8
+ const { renderWithI18n } = rtlHelpers;
9
+
10
+ const renderInsightsTab = (props = {}) =>
11
+ renderWithI18n(
12
+ <InsightsTab hostID={hostID} hits={[]} fetchHits={jest.fn()} {...props} />
13
+ );
5
14
 
6
15
  describe('InsightsTab', () => {
7
- it('renders "No recommendations" message when hits is empty', () => {
8
- render(<InsightsTab hostID={hostID} hits={[]} />);
16
+ it('renders empty state heading when there are no recommendations', async () => {
17
+ renderInsightsTab({ hits: [] });
18
+
19
+ expect(
20
+ await screen.findByRole('heading', {
21
+ name: 'No recommendations were found for this host!',
22
+ })
23
+ ).toBeInTheDocument();
24
+
9
25
  expect(
10
- screen.getByText('No recommendations were found for this host!')
11
- ).toBeTruthy();
26
+ screen.queryByRole('heading', { name: 'Recommendations' })
27
+ ).not.toBeInTheDocument();
12
28
  });
13
29
 
14
- it('renders Recommendations heading when hits exist', () => {
15
- render(<InsightsTab hostID={hostID} hits={hits} />);
16
- expect(screen.getByText('Recommendations')).toBeTruthy();
30
+ it('renders recommendations heading when hits exist', async () => {
31
+ renderInsightsTab({ hits });
32
+
33
+ expect(
34
+ await screen.findByRole('heading', { name: 'Recommendations' })
35
+ ).toBeInTheDocument();
17
36
  });
18
37
 
19
- it('displays hit titles', () => {
20
- render(<InsightsTab hostID={hostID} hits={hits} />);
21
- hits.forEach(hit => {
22
- expect(screen.getByText(hit.title)).toBeTruthy();
23
- });
38
+ it('displays recommendation titles in the list', async () => {
39
+ renderInsightsTab({ hits });
40
+
41
+ expect(
42
+ await screen.findByText(
43
+ 'New Ansible Engine packages are inaccessible when dedicated Ansible repo is not enabled'
44
+ )
45
+ ).toBeInTheDocument();
24
46
  });
25
47
 
26
- it('calls fetchHits on mount', () => {
48
+ it('requests recommendations for the host on mount', async () => {
27
49
  const fetchHits = jest.fn();
28
- render(<InsightsTab hostID={hostID} hits={[]} fetchHits={fetchHits} />);
29
- expect(fetchHits).toHaveBeenCalledWith(hostID);
50
+
51
+ renderInsightsTab({ hits: [], fetchHits });
52
+
53
+ await waitFor(() => {
54
+ expect(fetchHits).toHaveBeenCalledWith(hostID);
55
+ });
30
56
  });
31
57
 
32
- it('sorts hits by total_risk descending', () => {
33
- const multipleHits = [
34
- { ...hits[0], title: 'Low risk', total_risk: 1 },
35
- { ...hits[0], title: 'High risk', total_risk: 4 },
36
- { ...hits[0], title: 'Medium risk', total_risk: 2 },
37
- ];
38
- render(<InsightsTab hostID={hostID} hits={multipleHits} />);
39
- const titles = screen.getAllByText(/risk/i).map(el => el.textContent);
40
- const highRiskIndex = titles.findIndex(t => t === 'High risk');
41
- const mediumRiskIndex = titles.findIndex(t => t === 'Medium risk');
42
- const lowRiskIndex = titles.findIndex(t => t === 'Low risk');
43
- expect(highRiskIndex).toBeLessThan(mediumRiskIndex);
44
- expect(mediumRiskIndex).toBeLessThan(lowRiskIndex);
58
+ it('sorts recommendations by total risk descending', async () => {
59
+ renderInsightsTab({ hits: multipleHits });
60
+
61
+ const hitsList = await screen.findByTestId('hits-list');
62
+ const toggles = within(hitsList).getAllByRole('button');
63
+
64
+ expect(toggles[0]).toHaveTextContent('High risk');
65
+ expect(toggles[1]).toHaveTextContent('Medium risk');
66
+ expect(toggles[2]).toHaveTextContent('Low risk');
45
67
  });
46
68
  });
@@ -1,33 +1,24 @@
1
- import React, { Fragment } from 'react';
2
- import { ListView, Icon } from 'patternfly-react';
1
+ import React, { useState } from 'react';
2
+ import {
3
+ AccordionContent,
4
+ AccordionItem,
5
+ AccordionToggle,
6
+ Flex,
7
+ FlexItem,
8
+ Truncate,
9
+ } from '@patternfly/react-core';
10
+ import { ExternalLinkAltIcon } from '@patternfly/react-icons';
3
11
  import PropTypes from 'prop-types';
4
12
  import { translate as __ } from 'foremanReact/common/I18n';
5
-
6
- const labelMapper = {
7
- 1: __('Low'),
8
- 2: __('Moderate'),
9
- 3: __('Important'),
10
- 4: __('Critical'),
11
- };
13
+ import InsightsLabel from '../../../InsightsCloudSync/Components/InsightsTable/InsightsLabel';
12
14
 
13
15
  const ListItem = ({ title, totalRisk, resultsUrl, solutionUrl }) => {
14
- const heading = (
15
- <p className="ellipsis list-item-heading" title={title}>
16
- {title}
17
- </p>
18
- );
19
-
20
- const riskLabel = labelMapper[totalRisk];
21
- const additionalInfo = [
22
- <span key={`risk-info-${title}`} className={`risk-label risk-${totalRisk}`}>
23
- <p>{riskLabel}</p>
24
- </span>,
25
- ];
16
+ const [isExpanded, setIsExpanded] = useState(false);
26
17
 
27
18
  const knowledgebaseLink = solutionUrl && (
28
19
  <p>
29
20
  <a href={solutionUrl} target="_blank" rel="noopener noreferrer">
30
- {__('Knowledgebase article')} <Icon name="external-link" />
21
+ {__('Knowledgebase article')} <ExternalLinkAltIcon />
31
22
  </a>
32
23
  </p>
33
24
  );
@@ -35,24 +26,41 @@ const ListItem = ({ title, totalRisk, resultsUrl, solutionUrl }) => {
35
26
  const insightsCloudLink = resultsUrl && (
36
27
  <p>
37
28
  <a href={resultsUrl} target="_blank" rel="noopener noreferrer">
38
- {__('Read more about it in RH cloud insights')}{' '}
39
- <Icon name="external-link" />
29
+ {__('Read more about it in RH cloud insights')}
30
+ <ExternalLinkAltIcon />
40
31
  </a>
41
32
  </p>
42
33
  );
43
34
 
44
35
  return (
45
- <ListView.Item
46
- heading={heading}
47
- additionalInfo={additionalInfo}
48
- hideCloseIcon
49
- >
50
- <Fragment>
51
- <p>{title}</p>
52
- {knowledgebaseLink}
53
- {insightsCloudLink}
54
- </Fragment>
55
- </ListView.Item>
36
+ <AccordionItem>
37
+ <AccordionToggle
38
+ onClick={() => setIsExpanded(currentValue => !currentValue)}
39
+ isExpanded={isExpanded}
40
+ >
41
+ <Flex
42
+ alignItems={{ default: 'alignItemsCenter' }}
43
+ justifyContent={{ default: 'justifyContentSpaceBetween' }}
44
+ flexWrap={{ default: 'nowrap' }}
45
+ >
46
+ <FlexItem>
47
+ <Truncate content={title} />
48
+ </FlexItem>
49
+ <FlexItem>
50
+ <InsightsLabel value={totalRisk} />
51
+ </FlexItem>
52
+ </Flex>
53
+ </AccordionToggle>
54
+ <AccordionContent isHidden={!isExpanded}>
55
+ {isExpanded && (
56
+ <>
57
+ <p>{title}</p>
58
+ {knowledgebaseLink}
59
+ {insightsCloudLink}
60
+ </>
61
+ )}
62
+ </AccordionContent>
63
+ </AccordionItem>
56
64
  );
57
65
  };
58
66
 
@@ -0,0 +1,111 @@
1
+ import React from 'react';
2
+ import { screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { Accordion } from '@patternfly/react-core';
6
+ import { rtlHelpers } from 'foremanReact/common/rtlTestHelpers';
7
+ import ListItem from '../ListItem';
8
+
9
+ const { renderWithI18n } = rtlHelpers;
10
+
11
+ const defaultProps = {
12
+ title: 'Test recommendation title',
13
+ totalRisk: 2,
14
+ resultsUrl:
15
+ 'https://cloud.redhat.com/insights/advisor/recommendations/example',
16
+ solutionUrl: 'https://access.redhat.com/solutions/123456',
17
+ };
18
+
19
+ const renderListItem = (props = {}) =>
20
+ renderWithI18n(
21
+ <Accordion>
22
+ <ListItem {...defaultProps} {...props} />
23
+ </Accordion>
24
+ );
25
+
26
+ describe('ListItem', () => {
27
+ it('renders the recommendation title when collapsed', async () => {
28
+ renderListItem();
29
+
30
+ expect(
31
+ await screen.findByText('Test recommendation title')
32
+ ).toBeInTheDocument();
33
+ });
34
+
35
+ it('renders the total risk label when collapsed', async () => {
36
+ renderListItem({ totalRisk: 2 });
37
+
38
+ expect(await screen.findByText('Moderate')).toBeInTheDocument();
39
+ });
40
+
41
+ it('does not show detail links before expanding', async () => {
42
+ renderListItem();
43
+
44
+ await screen.findByText('Test recommendation title');
45
+
46
+ expect(
47
+ screen.queryByRole('link', { name: /Knowledgebase article/i })
48
+ ).not.toBeInTheDocument();
49
+ expect(
50
+ screen.queryByRole('link', {
51
+ name: /Read more about it in RH cloud insights/i,
52
+ })
53
+ ).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('shows detail links after expanding the recommendation', async () => {
57
+ renderListItem();
58
+
59
+ await userEvent.click(await screen.findByRole('button'));
60
+
61
+ const knowledgebaseLink = screen.getByRole('link', {
62
+ name: /Knowledgebase article/i,
63
+ });
64
+ expect(knowledgebaseLink).toHaveAttribute('href', defaultProps.solutionUrl);
65
+ expect(knowledgebaseLink).toHaveAttribute('target', '_blank');
66
+ expect(knowledgebaseLink).toHaveAttribute('rel', 'noopener noreferrer');
67
+
68
+ const insightsCloudLink = screen.getByRole('link', {
69
+ name: /Read more about it in RH cloud insights/i,
70
+ });
71
+ expect(insightsCloudLink).toHaveAttribute('href', defaultProps.resultsUrl);
72
+ expect(insightsCloudLink).toHaveAttribute('target', '_blank');
73
+ expect(insightsCloudLink).toHaveAttribute('rel', 'noopener noreferrer');
74
+ });
75
+
76
+ it('omits the knowledgebase link when no solution URL is provided', async () => {
77
+ renderListItem({ solutionUrl: '' });
78
+
79
+ await userEvent.click(await screen.findByRole('button'));
80
+
81
+ expect(
82
+ screen.queryByRole('link', { name: /Knowledgebase article/i })
83
+ ).not.toBeInTheDocument();
84
+
85
+ const insightsCloudLink = screen.getByRole('link', {
86
+ name: /Read more about it in RH cloud insights/i,
87
+ });
88
+ expect(insightsCloudLink).toBeInTheDocument();
89
+ expect(insightsCloudLink).toHaveAttribute('target', '_blank');
90
+ expect(insightsCloudLink).toHaveAttribute('rel', 'noopener noreferrer');
91
+ });
92
+
93
+ it('omits the insights cloud link when no results URL is provided', async () => {
94
+ renderListItem({ resultsUrl: '' });
95
+
96
+ await userEvent.click(await screen.findByRole('button'));
97
+
98
+ const knowledgebaseLink = screen.getByRole('link', {
99
+ name: /Knowledgebase article/i,
100
+ });
101
+ expect(knowledgebaseLink).toBeInTheDocument();
102
+ expect(knowledgebaseLink).toHaveAttribute('target', '_blank');
103
+ expect(knowledgebaseLink).toHaveAttribute('rel', 'noopener noreferrer');
104
+
105
+ expect(
106
+ screen.queryByRole('link', {
107
+ name: /Read more about it in RH cloud insights/i,
108
+ })
109
+ ).not.toBeInTheDocument();
110
+ });
111
+ });
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { useRouteMatch } from 'react-router-dom';
3
+ import { ScalprumComponent, ScalprumProvider } from '@scalprum/react-core';
4
+
5
+ import RemediationModal from '../InsightsCloudSync/Components/RemediationModal';
6
+ import { createProviderOptions } from '../common/ScalprumModule/ScalprumContext';
7
+ import { useInsightsPermissions } from '../common/Hooks/PermissionsHooks';
8
+
9
+ const scope = 'advisor';
10
+ const pathwayModule = './PathwayDetailsWrapped';
11
+
12
+ const IopPathwayDetails = props => {
13
+ const pathwayMatch = useRouteMatch(
14
+ '/foreman_rh_cloud/recommendations/pathways/:slug'
15
+ );
16
+ const slug = pathwayMatch?.params?.slug;
17
+
18
+ return (
19
+ <div className="iop-pathway-details-scalprum advisor">
20
+ <ScalprumComponent
21
+ scope={scope}
22
+ module={pathwayModule}
23
+ pathwayId={slug}
24
+ IopRemediationModal={RemediationModal}
25
+ {...props}
26
+ />
27
+ </div>
28
+ );
29
+ };
30
+
31
+ const IopPathwayDetailsWrapped = props => {
32
+ const permissions = useInsightsPermissions();
33
+ return (
34
+ <ScalprumProvider {...createProviderOptions(permissions)}>
35
+ <IopPathwayDetails {...props} />
36
+ </ScalprumProvider>
37
+ );
38
+ };
39
+
40
+ export default IopPathwayDetailsWrapped;
@@ -13,6 +13,7 @@ const IopRecommendationDetails = props => {
13
13
  const urlParams = useRouteMatch('/foreman_rh_cloud/recommendations/:rule_id');
14
14
  // eslint-disable-next-line camelcase
15
15
  const ruleId = urlParams?.params?.rule_id;
16
+
16
17
  return (
17
18
  <div className="iop-recommendation-details-scalprum advisor">
18
19
  <ScalprumComponent
@@ -1,4 +1,8 @@
1
- export const withInterval = (action, interval = 3000) => ({
1
+ // Arbitrary fallback for this test double only; the real foremanReact
2
+ // default lives in IntervalMiddleware/IntervalConstants.js.
3
+ const DEFAULT_INTERVAL_MS = 3000;
4
+
5
+ export const withInterval = (action, interval = DEFAULT_INTERVAL_MS) => ({
2
6
  ...action,
3
7
  interval,
4
8
  });
@@ -18,6 +18,7 @@ const DropdownToggle = ({ items, ...props }) => {
18
18
  aria-label={__('Table actions')}
19
19
  onClick={() => setOpen(prev => !prev)}
20
20
  isExpanded={isOpen}
21
+ ouiaId="toggle-dropdown-toggle"
21
22
  >
22
23
  <EllipsisVIcon />
23
24
  </MenuToggle>
@@ -1,18 +1,20 @@
1
1
  import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Popover } from '@patternfly/react-core';
3
+ import { Button, Popover } from '@patternfly/react-core';
4
4
  import { HelpIcon } from '@patternfly/react-icons';
5
5
 
6
6
  export const HelpLabel = ({ text, id, className }) => {
7
7
  if (!text) return null;
8
+
8
9
  return (
9
10
  <Popover id={`${id}-help`} bodyContent={text} aria-label="help-text">
10
- <button
11
- onClick={e => e.preventDefault()}
12
- className={`pf-v5-c-form__group-label-help ${className}`}
11
+ <Button
12
+ variant="plain"
13
+ ouiaId={`help-button-${id}`}
14
+ className={className}
13
15
  >
14
16
  <HelpIcon />
15
- </button>
17
+ </Button>
16
18
  </Popover>
17
19
  );
18
20
  };
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_rh_cloud
3
3
  version: !ruby/object:Gem::Version
4
- version: 14.2.1
4
+ version: 14.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Red Hat Cloud team
@@ -381,6 +381,7 @@ files:
381
381
  - webpack/ForemanInventoryUpload/Components/PageHeader/components/ToolbarButtons/index.js
382
382
  - webpack/ForemanInventoryUpload/Components/PageHeader/components/ToolbarButtons/toolbarButtons.scss
383
383
  - webpack/ForemanInventoryUpload/Components/PageHeader/index.js
384
+ - webpack/ForemanInventoryUpload/Components/TaskConstants.js
384
385
  - webpack/ForemanInventoryUpload/Components/TaskHistory/TaskHistory.js
385
386
  - webpack/ForemanInventoryUpload/Components/TaskHistory/index.js
386
387
  - webpack/ForemanInventoryUpload/Components/TaskHistory/taskHistory.scss
@@ -463,6 +464,7 @@ files:
463
464
  - webpack/InsightsHostDetailsTab/__tests__/InsightsTotalRiskChart.test.js
464
465
  - webpack/InsightsHostDetailsTab/__tests__/NewHostDetailsTab.test.js
465
466
  - webpack/InsightsHostDetailsTab/components/ListItem/ListItem.js
467
+ - webpack/InsightsHostDetailsTab/components/ListItem/__tests__/ListItem.test.js
466
468
  - webpack/InsightsHostDetailsTab/components/ListItem/index.js
467
469
  - webpack/InsightsHostDetailsTab/index.js
468
470
  - webpack/InsightsVulnerability/InsightsVulnerabilityListPage.js
@@ -474,6 +476,7 @@ files:
474
476
  - webpack/InsightsVulnerabilityActionsBar/index.js
475
477
  - webpack/InsightsVulnerabilityHostIndexExtensions/CVECountCell.js
476
478
  - webpack/InsightsVulnerabilityHostIndexExtensions/__tests__/CVECountCell.test.js
479
+ - webpack/IopPathwayDetails/IopPathwayDetails.js
477
480
  - webpack/IopRecommendationDetails/IopRecommendationDetails.js
478
481
  - webpack/__mocks__/foremanReact/Root/Context/ForemanContext.js
479
482
  - webpack/__mocks__/foremanReact/common/I18n.js
@@ -532,7 +535,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
532
535
  - !ruby/object:Gem::Version
533
536
  version: '0'
534
537
  requirements: []
535
- rubygems_version: 4.0.10
538
+ rubygems_version: 4.0.16
536
539
  specification_version: 4
537
540
  summary: Summary of ForemanRhCloud.
538
541
  test_files: