Skip to content

Commit e1465f8

Browse files
jeremylenzclaude
andcommitted
Refs #38901 - Address AI code review feedback
- Code quality: Inline variable in SyncStatusActions - Bug prevention: Clamp progress values 0-100 in SyncProgressCell - Bug fix: Handle object type for error_details in SyncResultCell - Accessibility: Fix ARIA setsize to reflect visible siblings - Accessibility: Hide empty parent nodes when filtering - Accessibility: Set aria-setsize=0 for leaf nodes to hide expand button - Performance: Set polling interval to 1 second - Tests: Add collapse, multi-select, and error handling tests - Cleanup: Remove commented sync_management routes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent d864fde commit e1465f8

File tree

10 files changed

+161
-70
lines changed

10 files changed

+161
-70
lines changed

app/helpers/katello/sync_management_helper.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ def format_repo(repo)
3939
end
4040

4141
# Recursively check if a node has any repositories
42-
def has_repos?(node)
42+
def repos?(node)
4343
return true if node[:repos].present? && node[:repos].any?
44-
return false unless node[:children].present?
45-
node[:children].any? { |child| has_repos?(child) }
44+
return false if node[:children].blank?
45+
node[:children].any? { |child| repos?(child) }
4646
end
4747

4848
# Filter out nodes with no repositories
4949
def filter_empty_nodes(nodes)
50-
nodes.select { |node| has_repos?(node) }.map do |node|
50+
nodes.select { |node| repos?(node) }.map do |node|
5151
if node[:children].present?
5252
node.merge(:children => filter_empty_nodes(node[:children]))
5353
else

config/routes.rb

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,6 @@
22
scope :katello, :path => '/katello' do
33
match ':kt_path/auto_complete_search', :action => :auto_complete_search, :controller => :auto_complete_search, :via => :get
44

5-
# Old sync_management controller kept for backward compatibility
6-
# Uncomment if needed for rollback
7-
# resources :sync_management, :only => [:destroy] do
8-
# collection do
9-
# get :index
10-
# get :sync_status
11-
# post :sync
12-
# end
13-
# end
14-
155
match '/remote_execution' => 'remote_execution#create', :via => [:post]
166
end
177

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
// Sticky toolbar and table header coordination
2-
// Target the toolbar by its ouiaId
3-
[data-ouia-component-id="sync-status-toolbar"] {
4-
position: sticky !important;
5-
top: 0 !important;
6-
z-index: 400 !important;
2+
// Target the toolbar by its ouiaId with more specific selector
3+
.pf-v5-c-toolbar.pf-m-sticky[data-ouia-component-id="sync-status-toolbar"] {
4+
position: sticky;
5+
top: 0;
6+
z-index: 400;
7+
box-shadow: none;
78
}
89

910
// Target the table by its ouiaId and offset the sticky header
10-
[data-ouia-component-id="sync-status-table"] {
11+
.pf-v5-c-table[data-ouia-component-id="sync-status-table"] {
1112
thead {
12-
position: sticky !important;
13-
top: 70px !important;
14-
z-index: 300 !important;
13+
position: sticky;
14+
top: 70px;
15+
z-index: 300;
1516
}
1617
}

webpack/scenes/SyncStatus/SyncStatusActions.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@ import SYNC_STATUS_KEY, {
88
} from './SyncStatusConstants';
99
import { getResponseErrorMsgs } from '../../utils/helpers';
1010

11-
export const syncStatusErrorToast = (error) => {
12-
const message = getResponseErrorMsgs(error.response);
13-
return message;
14-
};
11+
export const syncStatusErrorToast = error => getResponseErrorMsgs(error.response);
1512

1613
export const getSyncStatus = (extraParams = {}) => get({
1714
type: API_OPERATIONS.GET,

webpack/scenes/SyncStatus/SyncStatusPage.js

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import SyncStatusToolbar from './components/SyncStatusToolbar';
2424
import SyncStatusTable from './components/SyncStatusTable';
2525
import './SyncStatus.scss';
2626

27-
const POLL_INTERVAL = 5000; // Poll every 5 seconds
27+
const POLL_INTERVAL = 1000; // Poll every 1 second
2828

2929
const SyncStatusPage = () => {
3030
const dispatch = useDispatch();
@@ -74,14 +74,8 @@ const SyncStatusPage = () => {
7474
const selectedRepoIds = Array.from(selectionSet);
7575

7676
// Use refs for mutable values that don't need to trigger re-renders
77-
const repoStatusesRef = React.useRef(repoStatuses);
78-
const pollTimerRef = React.useRef(null);
7977
const hasAutoExpandedRef = React.useRef(false);
80-
81-
// Update ref whenever repo statuses change
82-
useEffect(() => {
83-
repoStatusesRef.current = repoStatuses;
84-
}, [repoStatuses]);
78+
const pollTimerRef = React.useRef(null);
8579

8680
// Load initial data
8781
useEffect(() => {
@@ -179,16 +173,12 @@ const SyncStatusPage = () => {
179173
if (syncingIds.length > 0 && !pollTimerRef.current) {
180174
// Start polling
181175
pollTimerRef.current = setInterval(() => {
182-
// Use ref to get current repo statuses instead of stale closure value
183-
const currentSyncingIds = Object.entries(repoStatusesRef.current)
176+
const currentSyncingIds = Object.entries(repoStatuses)
184177
.filter(([_, status]) => status?.is_running === true)
185178
.map(([id]) => parseInt(id, 10));
179+
186180
if (currentSyncingIds.length > 0) {
187181
dispatch(pollSyncStatus(currentSyncingIds));
188-
} else {
189-
// No more syncing repos, clear the timer
190-
clearInterval(pollTimerRef.current);
191-
pollTimerRef.current = null;
192182
}
193183
}, POLL_INTERVAL);
194184
} else if (syncingIds.length === 0 && pollTimerRef.current) {

webpack/scenes/SyncStatus/__tests__/SyncStatusPage.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,21 @@ test('Displays empty state when no products exist', async () => {
9090

9191
assertNockRequest(scope);
9292
});
93+
94+
test('Handles API error (500) gracefully', async () => {
95+
const scope = nockInstance
96+
.get(syncStatusPath)
97+
.query(true)
98+
.reply(500, { error: 'Internal Server Error' });
99+
100+
const { queryByText } = renderWithRedux(<SyncStatusPage />, renderOptions);
101+
102+
await patientlyWaitFor(() => {
103+
// Page should still render the header even with error
104+
expect(queryByText('Sync Status')).toBeInTheDocument();
105+
// Products won't be shown since API failed
106+
expect(queryByText('Test Product')).toBeNull();
107+
});
108+
109+
assertNockRequest(scope);
110+
});

webpack/scenes/SyncStatus/components/SyncProgressCell.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const SyncProgressCell = ({ repo, onCancelSync }) => {
1515
return null;
1616
}
1717

18-
const progressValue = progress?.progress || 0;
18+
const progressValue = Math.max(0, Math.min(100, progress?.progress || 0));
1919

2020
return (
2121
<Flex alignItems={{ default: 'alignItemsCenter' }}>

webpack/scenes/SyncStatus/components/SyncResultCell.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,14 @@ const SyncResultCell = ({ repo }) => {
5858
);
5959

6060
if (errorDetails) {
61-
const errorText = Array.isArray(errorDetails)
62-
? errorDetails.join('\n')
63-
: errorDetails;
61+
let errorText;
62+
if (Array.isArray(errorDetails)) {
63+
errorText = errorDetails.join('\n');
64+
} else if (typeof errorDetails === 'object') {
65+
errorText = JSON.stringify(errorDetails, null, 2);
66+
} else {
67+
errorText = String(errorDetails);
68+
}
6469

6570
if (errorText && errorText.length > 0) {
6671
return (
@@ -80,9 +85,11 @@ SyncResultCell.propTypes = {
8085
state: PropTypes.string,
8186
start_time: PropTypes.string,
8287
sync_id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
88+
// error_details can be string, array, or object from API
8389
error_details: PropTypes.oneOfType([
8490
PropTypes.string,
8591
PropTypes.arrayOf(PropTypes.string),
92+
PropTypes.object,
8693
]),
8794
}).isRequired,
8895
};

webpack/scenes/SyncStatus/components/SyncStatusTable.js

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,18 @@ const SyncStatusTable = ({
8383
const buildTreeRows = useMemo(() => {
8484
const rows = [];
8585

86-
const addRow = (node, level, parent, posinset, isHidden) => {
86+
// Helper to check if a node will be visible (for setsize calculation)
87+
const isNodeVisible = (node) => {
88+
if (!showActiveOnly) return true;
89+
if (node.type === 'repo') {
90+
const status = repoStatuses[node.id];
91+
return status?.is_running;
92+
}
93+
// For non-repo nodes, they're visible if shown
94+
return true;
95+
};
96+
97+
const addRow = (node, level, parent, posinset, ariaSetSize, isHidden) => {
8798
const nodeId = `${node.type}-${node.id}`;
8899
const hasChildren = (node.children && node.children.length > 0) ||
89100
(node.repos && node.repos.length > 0);
@@ -95,6 +106,7 @@ const SyncStatusTable = ({
95106
level,
96107
parent,
97108
posinset,
109+
ariaSetSize,
98110
isHidden,
99111
hasChildren,
100112
isExpanded,
@@ -105,28 +117,58 @@ const SyncStatusTable = ({
105117
const reposToRender = node.repos || [];
106118
const allChildren = [...childrenToRender, ...reposToRender];
107119

120+
// Calculate visible siblings for aria-setsize
121+
const visibleChildren = allChildren.filter(isNodeVisible);
122+
const visibleCount = visibleChildren.length;
123+
108124
allChildren.forEach((child, idx) => {
109-
addRow(child, level + 1, nodeId, idx + 1, !isExpanded || isHidden);
125+
// Use position among all children for posinset, but visible count for setsize
126+
addRow(child, level + 1, nodeId, idx + 1, visibleCount, !isExpanded || isHidden);
110127
});
111128
}
112129
};
113130

131+
// For root products, calculate visible products
132+
const visibleProducts = products.filter(isNodeVisible);
114133
products.forEach((product, idx) => {
115-
addRow(product, 1, null, idx + 1, false);
134+
addRow(product, 1, null, idx + 1, visibleProducts.length, false);
116135
});
117136

118137
return rows;
119-
}, [products, expandedNodeIds]);
138+
}, [products, expandedNodeIds, showActiveOnly, repoStatuses]);
120139

121140
// Filter rows based on active only setting
122141
const visibleRows = useMemo(() => {
123142
if (!showActiveOnly) return buildTreeRows;
124143

125-
return buildTreeRows.filter((row) => {
126-
if (row.type !== 'repo') return true;
127-
const status = repoStatuses[row.id];
128-
return status?.is_running;
144+
// Build parent->children map
145+
const parentToChildren = {};
146+
buildTreeRows.forEach((row) => {
147+
if (row.parent) {
148+
if (!parentToChildren[row.parent]) parentToChildren[row.parent] = [];
149+
parentToChildren[row.parent].push(row);
150+
}
129151
});
152+
153+
// Recursive helper functions for visibility checking
154+
// hasVisibleChildren must be defined before isRowVisible due to ESLint rules
155+
function hasVisibleChildren(row) {
156+
const children = parentToChildren[row.nodeId] || [];
157+
// eslint-disable-next-line no-use-before-define
158+
return children.some(child => isRowVisible(child));
159+
}
160+
161+
// Check if a row should be visible
162+
function isRowVisible(row) {
163+
if (row.type === 'repo') {
164+
const status = repoStatuses[row.id];
165+
return status?.is_running;
166+
}
167+
// For non-repo nodes (product, minor, arch), visible if has visible children
168+
return hasVisibleChildren(row);
169+
}
170+
171+
return buildTreeRows.filter(row => isRowVisible(row));
130172
}, [buildTreeRows, showActiveOnly, repoStatuses]);
131173

132174
const toggleExpand = (nodeId) => {
@@ -150,21 +192,29 @@ const SyncStatusTable = ({
150192
// Get checkbox state for selectable nodes
151193
const nodeCheckboxState = isSelectableNode ? getNodeCheckboxState(row) : null;
152194

153-
const treeRow = {
154-
onCollapse: row.hasChildren ? () => toggleExpand(row.nodeId) : () => {},
195+
// Build treeRow props - minimal for leaf nodes, full for expandable nodes
196+
const treeRow = row.hasChildren ? {
197+
onCollapse: () => toggleExpand(row.nodeId),
155198
props: {
156199
'aria-level': row.level,
157200
'aria-posinset': row.posinset,
158-
'aria-setsize': row.hasChildren ? (row.children?.length || 0) + (row.repos?.length || 0) : 0,
201+
'aria-setsize': row.ariaSetSize || 0,
159202
isExpanded: row.isExpanded,
160203
isHidden: row.isHidden,
161204
},
205+
} : {
206+
props: {
207+
'aria-level': row.level,
208+
'aria-posinset': row.posinset,
209+
'aria-setsize': 0, // MUST be 0 for leaf nodes to hide expand button
210+
isHidden: row.isHidden,
211+
},
162212
};
163213

164214
return (
165215
<TreeRowWrapper
166216
key={row.nodeId}
167-
row={treeRow}
217+
row={row.hasChildren ? treeRow : { props: treeRow.props }}
168218
>
169219
<Td dataLabel={__('Name')} treeRow={treeRow}>
170220
{isRepo && (
@@ -237,14 +287,13 @@ const SyncStatusTable = ({
237287
};
238288

239289
return (
240-
<div style={{ paddingTop: '8px' }}>
241-
<Table
242-
aria-label={__('Sync Status')}
243-
variant="compact"
244-
isTreeTable
245-
isStickyHeader
246-
ouiaId="sync-status-table"
247-
>
290+
<Table
291+
aria-label={__('Sync Status')}
292+
variant="compact"
293+
isTreeTable
294+
isStickyHeader
295+
ouiaId="sync-status-table"
296+
>
248297
<Thead>
249298
<Tr ouiaId="sync-status-table-header">
250299
<Th
@@ -273,7 +322,6 @@ const SyncStatusTable = ({
273322
{visibleRows.map(row => renderRow(row))}
274323
</Tbody>
275324
</Table>
276-
</div>
277325
);
278326
};
279327

webpack/scenes/SyncStatus/components/__tests__/SyncStatusTable.test.js

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,56 @@ describe('SyncStatusTable', () => {
124124
expect(screen.getByText('Test Repository')).toBeInTheDocument();
125125
});
126126

127-
it('still shows product hierarchy even when repo is not active with showActiveOnly', () => {
127+
it('hides product when all child repos are not active with showActiveOnly', () => {
128128
const propsWithActiveOnly = {
129129
...mockProps,
130130
showActiveOnly: true,
131131
};
132132

133133
render(<SyncStatusTable {...propsWithActiveOnly} />);
134134

135-
// Filter only hides repos that are not running, but still shows product
136-
// and content nodes since they're not repos
137-
expect(screen.getByText('Test Product')).toBeInTheDocument();
135+
// Filter hides parent nodes when all child repos are not running
136+
expect(screen.queryByText('Test Product')).not.toBeInTheDocument();
137+
expect(screen.queryByText('Test Repository')).not.toBeInTheDocument();
138+
});
139+
140+
it('collapses expanded row when expand button is clicked again', () => {
141+
const propsWithExpanded = {
142+
...mockProps,
143+
expandedNodeIds: ['product-1'],
144+
};
145+
146+
render(<SyncStatusTable {...propsWithExpanded} />);
147+
148+
// Find and click the expand button to collapse
149+
const expandButtons = screen.getAllByRole('button', { name: /collapse row/i });
150+
fireEvent.click(expandButtons[0]);
151+
152+
// Should call setExpandedNodeIds to collapse
153+
expect(mockProps.setExpandedNodeIds).toHaveBeenCalled();
154+
const setExpandedCall = mockProps.setExpandedNodeIds.mock.calls[0][0];
155+
const newExpandedIds = setExpandedCall(['product-1']);
156+
expect(newExpandedIds).toEqual([]);
157+
});
158+
159+
it('selects multiple repositories', () => {
160+
const propsWithExpandedNodes = {
161+
...mockProps,
162+
expandedNodeIds: ['product-1', 'product_content-101'],
163+
};
164+
165+
render(<SyncStatusTable {...propsWithExpandedNodes} />);
166+
167+
// Find all checkboxes
168+
const checkboxes = screen.getAllByRole('checkbox');
169+
// eslint-disable-next-line promise/prefer-await-to-callbacks
170+
const repoCheckboxes = checkboxes.filter(cb =>
171+
cb.getAttribute('aria-label')?.includes('Select repository'));
172+
173+
// Click the first repo checkbox
174+
if (repoCheckboxes[0]) {
175+
fireEvent.click(repoCheckboxes[0]);
176+
expect(mockProps.onSelectRepo).toHaveBeenCalledTimes(1);
177+
}
138178
});
139179
});

0 commit comments

Comments
 (0)