foreman_leapp 4.0.0 → 4.1.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.
@@ -4,11 +4,19 @@
4
4
  }
5
5
  }
6
6
 
7
+ .leapp-searchbar-item {
8
+ min-width: 200px;
9
+ flex-grow: 1;
10
+ }
11
+
7
12
  .leapp-expanded-tbody {
8
13
  border: 1px solid var(--pf-v5-global--BorderColor--100);
9
14
  box-shadow: var(--pf-v5-global--BoxShadow--sm);
10
15
 
11
16
  tr:first-child > td {
12
- background-color: var(--pf-v5-global--primary-color--light-background, #e7f1fa);
17
+ background-color: var(
18
+ --pf-v5-global--primary-color--light-background,
19
+ #e7f1fa
20
+ );
13
21
  }
14
22
  }
@@ -0,0 +1,164 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { useDispatch } from 'react-redux';
3
+ import { APIActions } from 'foremanReact/redux/API';
4
+ import { STATUS } from 'foremanReact/constants';
5
+ import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
6
+
7
+ export const usePreupgradeTableState = (data, isExpanded) => {
8
+ const dispatch = useDispatch();
9
+ const lastFetchedKeyRef = useRef(null);
10
+ const lastEntriesFetchKeyRef = useRef(null);
11
+ const { perPage: foremanPerPage } = useForemanSettings();
12
+
13
+ const [status, setStatus] = useState(STATUS.RESOLVED);
14
+ const [error, setError] = useState(null);
15
+ const [reportId, setReportId] = useState(null);
16
+ const [rows, setRows] = useState([]);
17
+ const [totalCount, setTotalCount] = useState(0);
18
+ const [searchValue, setSearchValue] = useState('');
19
+ const [pagination, setPagination] = useState({
20
+ page: 1,
21
+ perPage: foremanPerPage,
22
+ });
23
+ const [sortBy, setSortBy] = useState({ index: '', direction: 'asc' });
24
+ const [expandedRowIds, setExpandedRowIds] = useState(new Set());
25
+
26
+ // eslint-disable-next-line camelcase
27
+ const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp');
28
+ // eslint-disable-next-line camelcase
29
+ const jobStatusLabel = data?.status_label;
30
+
31
+ useEffect(() => {
32
+ const fetchKey = `${data.id}:${jobStatusLabel}`;
33
+ if (!isLeappJob || !isExpanded || lastFetchedKeyRef.current === fetchKey)
34
+ return undefined;
35
+
36
+ let ignore = false;
37
+ setStatus(STATUS.PENDING);
38
+
39
+ dispatch(
40
+ APIActions.get({
41
+ key: `GET_LEAPP_REPORT_LIST_${data.id}`,
42
+ url: `/api/v2/job_invocations/${data.id}/preupgrade_reports`,
43
+ handleSuccess: res => {
44
+ if (ignore) return;
45
+ lastFetchedKeyRef.current = fetchKey;
46
+ const payload = res.data || res;
47
+ const rawResults = payload.results;
48
+ const resultsArray = rawResults ? [].concat(rawResults) : [];
49
+
50
+ if (resultsArray[0]?.id) {
51
+ setReportId(resultsArray[0].id);
52
+ } else {
53
+ setRows([]);
54
+ setTotalCount(0);
55
+ setStatus(STATUS.RESOLVED);
56
+ }
57
+ },
58
+ handleError: err => {
59
+ if (ignore) return;
60
+ setError(err);
61
+ setStatus(STATUS.ERROR);
62
+ },
63
+ })
64
+ );
65
+
66
+ return () => {
67
+ ignore = true;
68
+ };
69
+ }, [isExpanded, data.id, isLeappJob, dispatch, jobStatusLabel]);
70
+
71
+ useEffect(() => {
72
+ if (!isLeappJob || !isExpanded || !reportId) return undefined;
73
+
74
+ const entriesFetchKey = `${reportId}_P${pagination.page}_PP${pagination.perPage}_S${searchValue}_O${sortBy.index}${sortBy.direction}_J${jobStatusLabel}`;
75
+ if (lastEntriesFetchKeyRef.current === entriesFetchKey) return undefined;
76
+
77
+ let ignore = false;
78
+ setStatus(STATUS.PENDING);
79
+
80
+ const orderParam = sortBy.index
81
+ ? `${sortBy.index} ${sortBy.direction.toUpperCase()}`
82
+ : undefined;
83
+
84
+ dispatch(
85
+ APIActions.get({
86
+ key: `GET_LEAPP_REPORT_ENTRIES_${entriesFetchKey}`,
87
+ url: `/api/v2/preupgrade_reports/${reportId}/preupgrade_report_entries`,
88
+ params: {
89
+ page: pagination.page,
90
+ per_page: pagination.perPage,
91
+ ...(searchValue && { search: searchValue }),
92
+ ...(orderParam && { order: orderParam }),
93
+ },
94
+ handleSuccess: res => {
95
+ if (ignore) return;
96
+ lastEntriesFetchKeyRef.current = entriesFetchKey;
97
+ const payload = res.data || res;
98
+ const fetchedResults = payload.results;
99
+ setRows(fetchedResults ? [].concat(fetchedResults) : []);
100
+ setTotalCount(payload.subtotal ?? 0);
101
+ setStatus(STATUS.RESOLVED);
102
+ setExpandedRowIds(new Set());
103
+ },
104
+ handleError: err => {
105
+ if (ignore) return;
106
+ setError(err);
107
+ setStatus(STATUS.ERROR);
108
+ },
109
+ })
110
+ );
111
+
112
+ return () => {
113
+ ignore = true;
114
+ };
115
+ }, [
116
+ isExpanded,
117
+ isLeappJob,
118
+ reportId,
119
+ searchValue,
120
+ pagination.page,
121
+ pagination.perPage,
122
+ sortBy.index,
123
+ sortBy.direction,
124
+ dispatch,
125
+ jobStatusLabel,
126
+ ]);
127
+
128
+ const areAllRowsExpanded =
129
+ rows.length > 0 && rows.every(row => expandedRowIds.has(row.id));
130
+
131
+ const onExpandAll = () => {
132
+ setExpandedRowIds(
133
+ areAllRowsExpanded ? new Set() : new Set(rows.map(r => r.id))
134
+ );
135
+ };
136
+
137
+ const toggleRowExpansion = (id, isOpen) => {
138
+ setExpandedRowIds(prev => {
139
+ const next = new Set(prev);
140
+ if (isOpen) next.add(id);
141
+ else next.delete(id);
142
+ return next;
143
+ });
144
+ };
145
+
146
+ return {
147
+ isLeappJob,
148
+ status,
149
+ error,
150
+ reportId,
151
+ rows,
152
+ totalCount,
153
+ pagination,
154
+ sortBy,
155
+ searchValue,
156
+ setSearchValue,
157
+ setPagination,
158
+ setSortBy,
159
+ expandedRowIds,
160
+ toggleRowExpansion,
161
+ areAllRowsExpanded,
162
+ onExpandAll,
163
+ };
164
+ };
@@ -1,23 +1,49 @@
1
- import '@testing-library/jest-dom/extend-expect';
2
-
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import { configureStore } from '@reduxjs/toolkit';
4
+ import Immutable from 'seamless-immutable';
3
5
  import {
4
- fireEvent,
5
6
  render,
6
7
  screen,
7
8
  waitFor,
9
+ fireEvent,
8
10
  within,
11
+ cleanup,
9
12
  } from '@testing-library/react';
10
-
13
+ import '@testing-library/jest-dom';
11
14
  import { APIActions } from 'foremanReact/redux/API';
12
15
  import PreupgradeReportsTable from '../index';
13
- import { Provider } from 'react-redux';
14
- import React from 'react';
15
- import configureMockStore from 'redux-mock-store';
16
- import thunk from 'redux-thunk';
17
16
 
18
17
  jest.mock('foremanReact/redux/API');
19
18
 
20
- const mockStore = configureMockStore([thunk]);
19
+ const mockReducer = (state = Immutable({ API: {} })) => state;
20
+
21
+ jest.mock('foremanReact/constants', () => ({
22
+ ...jest.requireActual('foremanReact/constants'),
23
+ getControllerSearchProps: jest.fn(() => ({
24
+ autocomplete: { url: '' },
25
+ bookmarks: { controller: 'preupgrade_report_entries', url: '/bookmarks' },
26
+ })),
27
+ STATUS: { PENDING: 'PENDING', RESOLVED: 'RESOLVED', ERROR: 'ERROR' },
28
+ }));
29
+
30
+ jest.mock('foremanReact/Root/Context/ForemanContext', () => ({
31
+ useForemanSettings: jest.fn(() => ({ perPage: 5 })),
32
+ }));
33
+
34
+ jest.mock('foremanReact/components/SearchBar', () => {
35
+ const MockSearchBar = ({ onSearch, onChange }) => (
36
+ <input
37
+ data-testid="search-input"
38
+ aria-label="Search preupgrade report entries"
39
+ onChange={e => onChange && onChange(e.target.value)}
40
+ onKeyDown={e => {
41
+ if (e.key === 'Enter') onSearch(e.target.value);
42
+ }}
43
+ />
44
+ );
45
+ return MockSearchBar;
46
+ });
21
47
 
22
48
  const mockJobId = 42;
23
49
  const mockReportId = 999;
@@ -26,9 +52,6 @@ const mockJobData = {
26
52
  template_name: 'Run preupgrade via Leapp',
27
53
  };
28
54
 
29
- // Entry 0 (id=1): command remediation + inhibitor flag → fixable + selectable
30
- // Entry 1 (id=2): hint-only remediation → has_remediation=Yes, NOT selectable
31
- // Entries 2-11: no remediations → has_remediation=No, NOT selectable
32
55
  const mockEntries = Array.from({ length: 12 }, (_, i) => ({
33
56
  id: i + 1,
34
57
  title: `Report Entry ${i + 1}`,
@@ -51,21 +74,25 @@ const mockEntries = Array.from({ length: 12 }, (_, i) => ({
51
74
  }));
52
75
 
53
76
  describe('PreupgradeReportsTable', () => {
54
- let store;
55
-
56
77
  beforeEach(() => {
57
- store = mockStore({ API: {} });
58
78
  jest.clearAllMocks();
59
79
 
60
80
  APIActions.get.mockImplementation(({ key, handleSuccess }) => {
61
- return dispatch => {
62
- if (key.includes('GET_LEAPP_REPORT_LIST'))
81
+ return () => {
82
+ if (key.includes('GET_LEAPP_REPORT_LIST')) {
63
83
  handleSuccess({ results: [{ id: mockReportId }] });
64
- if (key.includes('GET_LEAPP_REPORT_DETAIL'))
84
+ }
85
+ if (key.includes('GET_LEAPP_REPORT_ENTRIES')) {
65
86
  handleSuccess({
66
87
  id: mockReportId,
67
- preupgrade_report_entries: mockEntries,
88
+ results: mockEntries,
89
+ total: mockEntries.length,
90
+ subtotal: mockEntries.length,
68
91
  });
92
+ }
93
+ if (key.includes('GET_FIXABLE_COUNT')) {
94
+ handleSuccess({ total: 1, subtotal: 1 });
95
+ }
69
96
  return { type: 'MOCK_API_SUCCESS' };
70
97
  };
71
98
  });
@@ -73,15 +100,24 @@ describe('PreupgradeReportsTable', () => {
73
100
  APIActions.post.mockImplementation(() => () => ({ type: 'MOCK_API_POST' }));
74
101
  });
75
102
 
76
- const renderComponent = (data = mockJobData) =>
77
- render(
103
+ afterEach(() => {
104
+ cleanup();
105
+ });
106
+
107
+ const renderComponent = (data = mockJobData) => {
108
+ const store = configureStore({
109
+ reducer: mockReducer,
110
+ });
111
+ return render(
78
112
  <Provider store={store}>
79
113
  <PreupgradeReportsTable data={data} />
80
114
  </Provider>
81
115
  );
116
+ };
82
117
 
83
- const expandSection = () =>
118
+ const expandSection = () => {
84
119
  fireEvent.click(screen.getByText('Leapp preupgrade report'));
120
+ };
85
121
 
86
122
  const waitForTable = () =>
87
123
  waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
@@ -103,6 +139,9 @@ describe('PreupgradeReportsTable', () => {
103
139
  });
104
140
 
105
141
  it('refetches when status_label transitions (e.g. Running → Succeeded)', async () => {
142
+ const store = configureStore({
143
+ reducer: mockReducer,
144
+ });
106
145
  const { rerender } = render(
107
146
  <Provider store={store}>
108
147
  <PreupgradeReportsTable
@@ -151,8 +190,13 @@ describe('PreupgradeReportsTable', () => {
151
190
  return () => {
152
191
  if (key.includes('GET_LEAPP_REPORT_LIST'))
153
192
  handleSuccess({ results: [{ id: mockReportId }] });
154
- if (key.includes('GET_LEAPP_REPORT_DETAIL'))
155
- handleSuccess({ id: mockReportId, preupgrade_report_entries: [] });
193
+ if (key.includes('GET_LEAPP_REPORT_ENTRIES'))
194
+ handleSuccess({
195
+ id: mockReportId,
196
+ results: [],
197
+ total: 0,
198
+ subtotal: 0,
199
+ });
156
200
  return { type: 'EMPTY' };
157
201
  };
158
202
  });
@@ -175,7 +219,7 @@ describe('PreupgradeReportsTable', () => {
175
219
  ).toBeInTheDocument();
176
220
  });
177
221
 
178
- it('expands all rows', async () => {
222
+ it('expands all rows when expand-all is clicked', async () => {
179
223
  renderComponent();
180
224
  expandSection();
181
225
  await waitForTable();
@@ -188,6 +232,65 @@ describe('PreupgradeReportsTable', () => {
188
232
  ).toBeInTheDocument();
189
233
  });
190
234
 
235
+ it('collapses all rows when expand-all is clicked a second time', async () => {
236
+ renderComponent();
237
+ expandSection();
238
+ await waitForTable();
239
+
240
+ const expandAllButton = screen.getByLabelText('Expand all rows');
241
+ fireEvent.click(expandAllButton); // expand
242
+ await screen.findByText('Summary for report entry 1');
243
+ fireEvent.click(expandAllButton); // collapse
244
+
245
+ await waitFor(() => {
246
+ expect(
247
+ screen.queryByText('Summary for report entry 1')
248
+ ).not.toBeInTheDocument();
249
+ });
250
+ });
251
+
252
+ it('calls the show endpoint with search param', async () => {
253
+ renderComponent();
254
+ expandSection();
255
+ await waitForTable();
256
+
257
+ const input = screen.getByTestId('search-input');
258
+ fireEvent.change(input, { target: { value: 'title = Report' } });
259
+ fireEvent.keyDown(input, { key: 'Enter' });
260
+
261
+ await waitFor(() => {
262
+ const calls = APIActions.get.mock.calls
263
+ .flat()
264
+ .filter(
265
+ arg =>
266
+ arg?.key?.includes('GET_LEAPP_REPORT_ENTRIES') &&
267
+ arg?.params?.search
268
+ );
269
+ expect(calls.length).toBeGreaterThan(0);
270
+ expect(calls[0].params.search).toBe('title = Report');
271
+ });
272
+ });
273
+
274
+ it('calls the endpoint with sort parameters when a column header is clicked', async () => {
275
+ renderComponent();
276
+ expandSection();
277
+ await waitForTable();
278
+
279
+ const titleSortButton = screen.getByRole('button', { name: /Title/i });
280
+ fireEvent.click(titleSortButton);
281
+
282
+ await waitFor(() => {
283
+ const calls = APIActions.get.mock.calls
284
+ .flat()
285
+ .filter(
286
+ arg =>
287
+ arg?.key?.includes('GET_LEAPP_REPORT_ENTRIES') && arg?.params?.order
288
+ );
289
+ const lastCall = calls[calls.length - 1];
290
+ expect(lastCall.params.order).toContain('title');
291
+ });
292
+ });
293
+
191
294
  it('paginates to the next page', async () => {
192
295
  renderComponent();
193
296
  expandSection();
@@ -237,7 +340,6 @@ describe('PreupgradeReportsTable', () => {
237
340
  expandSection();
238
341
  await waitForTable();
239
342
 
240
- // id=1: command → Yes
241
343
  const row1 = screen
242
344
  .getByText('Report Entry 1', { selector: 'td' })
243
345
  .closest('tr');
@@ -247,7 +349,6 @@ describe('PreupgradeReportsTable', () => {
247
349
  )
248
350
  ).toBeInTheDocument();
249
351
 
250
- // id=2: hint-only → still Yes (display column shows any remediations)
251
352
  const row2 = screen
252
353
  .getByText('Report Entry 2', { selector: 'td' })
253
354
  .closest('tr');
@@ -257,7 +358,6 @@ describe('PreupgradeReportsTable', () => {
257
358
  )
258
359
  ).toBeInTheDocument();
259
360
 
260
- // id=3: no remediations → No
261
361
  const row3 = screen
262
362
  .getByText('Report Entry 3', { selector: 'td' })
263
363
  .closest('tr');
@@ -326,9 +426,52 @@ describe('PreupgradeReportsTable', () => {
326
426
 
327
427
  // checkboxes[2] = entry id=2, which has hint-only remediation — must be disabled
328
428
  const hintCheckbox = screen.getAllByRole('checkbox')[2];
429
+
329
430
  expect(hintCheckbox).toBeDisabled();
431
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
432
+ });
330
433
 
331
- fireEvent.click(hintCheckbox);
434
+ it('disables Fix Selected when selecting all on page with no fixable entries', async () => {
435
+ APIActions.get.mockImplementation(({ key, handleSuccess }) => {
436
+ return () => {
437
+ if (key.includes('GET_LEAPP_REPORT_LIST')) {
438
+ handleSuccess({ results: [{ id: mockReportId }] });
439
+ }
440
+ if (key.includes('GET_LEAPP_REPORT_ENTRIES')) {
441
+ handleSuccess({
442
+ id: mockReportId,
443
+ results: [
444
+ {
445
+ id: 999,
446
+ title: 'Non-fixable Entry',
447
+ hostname: 'test.com',
448
+ host_id: 200,
449
+ severity: 'low',
450
+ summary: 'This has no command remediation',
451
+ detail: {
452
+ remediations: [{ type: 'hint', context: 'Manual fix only' }],
453
+ },
454
+ },
455
+ ],
456
+ total: 1,
457
+ subtotal: 1,
458
+ });
459
+ }
460
+ if (key.includes('GET_FIXABLE_COUNT')) {
461
+ // No fixable entries in this test
462
+ handleSuccess({ total: 0, subtotal: 0 });
463
+ }
464
+ return { type: 'MOCK_API_SUCCESS' };
465
+ };
466
+ });
467
+
468
+ renderComponent();
469
+ expandSection();
470
+ await waitFor(() =>
471
+ screen.getByText('Non-fixable Entry', { selector: 'td' })
472
+ );
473
+
474
+ fireEvent.click(screen.getByLabelText('Select all'));
332
475
  expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
333
476
  });
334
477
 
@@ -363,13 +506,18 @@ describe('PreupgradeReportsTable', () => {
363
506
  expect(callParams.inputs).toBeUndefined();
364
507
  });
365
508
 
366
- it('does not render toolbar buttons when report has no entries', async () => {
509
+ it('renders toolbar buttons as disabled when report has no entries', async () => {
367
510
  APIActions.get.mockImplementation(({ key, handleSuccess }) => {
368
511
  return () => {
369
512
  if (key.includes('GET_LEAPP_REPORT_LIST'))
370
513
  handleSuccess({ results: [{ id: mockReportId }] });
371
- if (key.includes('GET_LEAPP_REPORT_DETAIL'))
372
- handleSuccess({ id: mockReportId, preupgrade_report_entries: [] });
514
+ if (key.includes('GET_LEAPP_REPORT_ENTRIES'))
515
+ handleSuccess({
516
+ id: mockReportId,
517
+ results: [],
518
+ total: 0,
519
+ subtotal: 0,
520
+ });
373
521
  return { type: 'EMPTY' };
374
522
  };
375
523
  });
@@ -378,12 +526,9 @@ describe('PreupgradeReportsTable', () => {
378
526
  await waitFor(() =>
379
527
  screen.getByText('The preupgrade report shows no issues.')
380
528
  );
381
- expect(
382
- screen.queryByRole('button', { name: 'Fix Selected' })
383
- ).not.toBeInTheDocument();
384
- expect(
385
- screen.queryByRole('button', { name: 'Run Upgrade' })
386
- ).not.toBeInTheDocument();
529
+
530
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
531
+ expect(screen.getByRole('button', { name: 'Run Upgrade' })).toBeDisabled();
387
532
  });
388
533
 
389
534
  it('renders the SelectAll checkbox', async () => {
@@ -406,4 +551,66 @@ describe('PreupgradeReportsTable', () => {
406
551
  ).not.toBeDisabled()
407
552
  );
408
553
  });
554
+
555
+ it('dispatches bulk_remediate API call when Fix Selected is clicked for all pages', async () => {
556
+ renderComponent();
557
+ expandSection();
558
+ await waitForTable();
559
+
560
+ fireEvent.click(screen.getByLabelText('Select all'));
561
+
562
+ await waitFor(() =>
563
+ expect(
564
+ screen.getByRole('button', { name: 'Fix Selected' })
565
+ ).not.toBeDisabled()
566
+ );
567
+ fireEvent.click(screen.getByRole('button', { name: 'Fix Selected' }));
568
+
569
+ expect(APIActions.post).toHaveBeenCalledWith(
570
+ expect.objectContaining({
571
+ url: expect.stringContaining('/bulk_remediate'),
572
+ params: {
573
+ search: '',
574
+ excluded_ids: [],
575
+ },
576
+ })
577
+ );
578
+ });
579
+
580
+ it('fetches fixable count on demand when Select All is clicked', async () => {
581
+ let fixableCountFetched = false;
582
+
583
+ APIActions.get.mockImplementation(({ key, handleSuccess, params }) => {
584
+ return () => {
585
+ if (key.includes('GET_LEAPP_REPORT_LIST')) {
586
+ handleSuccess({ results: [{ id: mockReportId }] });
587
+ }
588
+ if (key.includes('GET_LEAPP_REPORT_ENTRIES')) {
589
+ handleSuccess({
590
+ id: mockReportId,
591
+ results: mockEntries,
592
+ total: mockEntries.length,
593
+ subtotal: mockEntries.length,
594
+ });
595
+ }
596
+ if (key.includes('GET_FIXABLE_COUNT')) {
597
+ fixableCountFetched = true;
598
+ expect(params.search).toBe('fix_type = command');
599
+ expect(params.per_page).toBe(0);
600
+ handleSuccess({ total: 1, subtotal: 1 });
601
+ }
602
+ return { type: 'MOCK_API_SUCCESS' };
603
+ };
604
+ });
605
+
606
+ renderComponent();
607
+ expandSection();
608
+ await waitForTable();
609
+
610
+ fireEvent.click(screen.getByLabelText('Select all'));
611
+
612
+ await waitFor(() => {
613
+ expect(fixableCountFetched).toBe(true);
614
+ });
615
+ });
409
616
  });