foreman_scc_manager 5.0.4 → 5.2.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/javascripts/foreman_scc_manager/locale/de/foreman_scc_manager.js +55 -256
  4. data/app/assets/javascripts/foreman_scc_manager/locale/el/foreman_scc_manager.js +43 -244
  5. data/app/assets/javascripts/foreman_scc_manager/locale/en/foreman_scc_manager.js +381 -2
  6. data/app/assets/javascripts/foreman_scc_manager/locale/fr/foreman_scc_manager.js +58 -259
  7. data/app/assets/javascripts/foreman_scc_manager/locale/ja/foreman_scc_manager.js +58 -259
  8. data/app/assets/javascripts/foreman_scc_manager/locale/ka/foreman_scc_manager.js +58 -259
  9. data/app/assets/javascripts/foreman_scc_manager/locale/ko/foreman_scc_manager.js +55 -256
  10. data/app/assets/javascripts/foreman_scc_manager/locale/zh_CN/foreman_scc_manager.js +58 -259
  11. data/app/controllers/api/v2/scc_accounts_controller.rb +4 -2
  12. data/app/controllers/scc_accounts_controller.rb +0 -3
  13. data/app/models/scc_account.rb +1 -1
  14. data/app/views/scc_accounts/edit.html.erb +24 -2
  15. data/app/views/scc_accounts/index.html.erb +14 -33
  16. data/app/views/scc_accounts/new.html.erb +12 -1
  17. data/lib/foreman_scc_manager/version.rb +1 -1
  18. data/locale/de/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  19. data/locale/de/foreman_scc_manager.po +55 -257
  20. data/locale/el/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  21. data/locale/el/foreman_scc_manager.po +44 -246
  22. data/locale/en/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  23. data/locale/en/foreman_scc_manager.po +392 -0
  24. data/locale/foreman_scc_manager.pot +195 -461
  25. data/locale/fr/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  26. data/locale/fr/foreman_scc_manager.po +58 -260
  27. data/locale/ja/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  28. data/locale/ja/foreman_scc_manager.po +58 -260
  29. data/locale/ka/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  30. data/locale/ka/foreman_scc_manager.po +58 -260
  31. data/locale/ko/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  32. data/locale/ko/foreman_scc_manager.po +55 -257
  33. data/locale/zh_CN/LC_MESSAGES/foreman_scc_manager.mo +0 -0
  34. data/locale/zh_CN/foreman_scc_manager.po +58 -260
  35. data/test/controllers/api/v2/scc_accounts_test.rb +5 -5
  36. data/webpack/components/SCCAccountForm/SCCAccountForm.scss +49 -0
  37. data/webpack/components/SCCAccountForm/SCCAccountFormActions.js +74 -0
  38. data/webpack/components/SCCAccountForm/components/DateTimeField.js +72 -0
  39. data/webpack/components/SCCAccountForm/components/SCCCredentialsCard.js +150 -0
  40. data/webpack/components/SCCAccountForm/components/SCCSyncSettingsCard.js +256 -0
  41. data/webpack/components/SCCAccountForm/components/SCCTokenRefreshCard.js +133 -0
  42. data/webpack/components/SCCAccountForm/index.js +306 -0
  43. data/webpack/components/SCCAccountIndex/SCCAccountIndex.scss +26 -0
  44. data/webpack/components/SCCAccountIndex/SCCAccountIndex.test.js +291 -0
  45. data/webpack/components/SCCAccountIndex/SCCAccountIndexActions.js +205 -0
  46. data/webpack/components/SCCAccountIndex/SCCAccountIndexConstants.js +9 -0
  47. data/webpack/components/SCCAccountIndex/index.js +262 -0
  48. data/webpack/components/SCCProductPage/EmptySccProducts.js +10 -7
  49. data/webpack/components/SCCProductPage/components/SCCProductPicker/components/SCCGenericPicker/index.js +25 -11
  50. data/webpack/components/SCCProductPage/components/SCCProductPicker/styles.scss +8 -3
  51. data/webpack/components/SCCProductPage/sccProductPage.scss +5 -0
  52. data/webpack/index.js +12 -0
  53. metadata +14 -18
  54. data/app/assets/javascripts/foreman_scc_manager/scc_accounts.js.coffee +0 -46
  55. data/app/views/scc_accounts/_form.html.erb +0 -51
@@ -0,0 +1,306 @@
1
+ import React, { useState } from 'react';
2
+ import { useDispatch } from 'react-redux';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { foremanUrl } from 'foremanReact/common/helpers';
5
+ import PropTypes from 'prop-types';
6
+ import {
7
+ PageSection,
8
+ Title,
9
+ Form,
10
+ Button,
11
+ Stack,
12
+ StackItem,
13
+ } from '@patternfly/react-core';
14
+
15
+ import { formatDateTime } from './components/DateTimeField';
16
+ import SCCCredentialsCard from './components/SCCCredentialsCard';
17
+ import SCCTokenRefreshCard from './components/SCCTokenRefreshCard';
18
+ import SCCSyncSettingsCard from './components/SCCSyncSettingsCard';
19
+ import {
20
+ testSccConnectionAction,
21
+ submitSccAccountAction,
22
+ } from './SCCAccountFormActions';
23
+ import './SCCAccountForm.scss';
24
+
25
+ const SCCAccountForm = ({
26
+ organizationId,
27
+ intervalOptions,
28
+ downloadPolicyOptions,
29
+ mirroringPolicyOptions,
30
+ gpgKeyOptions,
31
+ selectedGpgKeyId,
32
+ initial,
33
+ }) => {
34
+ const isEdit = Boolean(initial?.id);
35
+ const dispatch = useDispatch();
36
+
37
+ const [openInterval, setOpenInterval] = useState(false);
38
+ const [openGpg, setOpenGpg] = useState(false);
39
+ const [openDownload, setOpenDownload] = useState(false);
40
+ const [openMirroring, setOpenMirroring] = useState(false);
41
+
42
+ const [name, setName] = useState(initial?.name ?? '');
43
+ const [login, setLogin] = useState(initial?.login ?? '');
44
+ const [password, setPassword] = useState(isEdit ? '********' : '');
45
+
46
+ const [baseUrl, setBaseUrl] = useState(
47
+ initial?.baseUrl ?? 'https://scc.suse.com'
48
+ );
49
+
50
+ const [refreshDate, setRefreshDate] = useState(initial?.refreshDate ?? '');
51
+ const [refreshTime, setRefreshTime] = useState(initial?.refreshTime ?? '');
52
+
53
+ const [testing, setTesting] = useState(false);
54
+ const [submitting, setSubmitting] = useState(false);
55
+
56
+ const [interval, setInterval] = useState(
57
+ initial?.interval ?? intervalOptions?.[0] ?? ''
58
+ );
59
+
60
+ const [gpgKey, setGpgKey] = useState(
61
+ initial?.gpgKey ?? selectedGpgKeyId ?? gpgKeyOptions?.[0]?.[0] ?? ''
62
+ );
63
+
64
+ const [downloadPolicy, setDownloadPolicy] = useState(
65
+ initial?.downloadPolicy ?? downloadPolicyOptions?.[0]?.[0] ?? ''
66
+ );
67
+
68
+ const [mirroringPolicy, setMirroringPolicy] = useState(
69
+ initial?.mirroringPolicy ?? mirroringPolicyOptions?.[0]?.[0] ?? ''
70
+ );
71
+
72
+ const handleUrl = (url) => {
73
+ if (!url) return;
74
+ try {
75
+ const parsed = new URL(url);
76
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
77
+ setBaseUrl(url);
78
+ return;
79
+ }
80
+ } catch {
81
+ // ignore parse errors
82
+ }
83
+ setBaseUrl(url);
84
+ };
85
+
86
+ const buildPayload = () => {
87
+ const downloadPolicyValue =
88
+ downloadPolicyOptions.find(([label]) => label === downloadPolicy)?.[1] ??
89
+ downloadPolicy;
90
+
91
+ const mirroringPolicyValue =
92
+ mirroringPolicyOptions.find(
93
+ ([label]) => label === mirroringPolicy
94
+ )?.[1] ?? mirroringPolicy;
95
+
96
+ const katelloGpgKeyId = !gpgKey
97
+ ? null
98
+ : gpgKeyOptions.find(([label]) => label === gpgKey)?.[1] ?? null;
99
+
100
+ const payload = {
101
+ name,
102
+ login,
103
+ password: isEdit && password === '********' ? null : password,
104
+ base_url: baseUrl,
105
+ interval,
106
+ download_policy: downloadPolicyValue,
107
+ mirroring_policy: mirroringPolicyValue,
108
+ organization_id: organizationId,
109
+ katello_gpg_key_id: katelloGpgKeyId,
110
+ };
111
+ const sync = formatDateTime(refreshDate, refreshTime);
112
+ if (sync) payload.sync_date = sync;
113
+
114
+ return payload;
115
+ };
116
+
117
+ const handleSubmit = (e) => {
118
+ e.preventDefault();
119
+ if (submitting) return;
120
+
121
+ setSubmitting(true);
122
+ const payload = buildPayload();
123
+
124
+ const action = submitSccAccountAction({
125
+ isEdit,
126
+ organizationId,
127
+ updateUrl: `/api/v2/scc_accounts/${initial?.id}`,
128
+ payload,
129
+ onSuccess: (redirect) => {
130
+ setSubmitting(false);
131
+ window.location.href = redirect;
132
+ },
133
+ onError: () => setSubmitting(false),
134
+ });
135
+
136
+ dispatch(action);
137
+ };
138
+ const handleTestConnection = () => {
139
+ setTesting(true);
140
+
141
+ const action = testSccConnectionAction({
142
+ login: isEdit && login === initial?.login ? '' : login,
143
+ password: isEdit && password === '********' ? '' : password,
144
+ baseUrl: isEdit && baseUrl === initial?.baseUrl ? '' : baseUrl,
145
+ isEdit,
146
+ id: initial?.id,
147
+ onFinally: () => setTesting(false),
148
+ });
149
+
150
+ const dispatchedAction = dispatch(action);
151
+ if (dispatchedAction?.finally)
152
+ dispatchedAction.finally(() => setTesting(false));
153
+ };
154
+
155
+ return (
156
+ <PageSection
157
+ isFilled
158
+ className="scc-account-form-section"
159
+ ouiaId="scc-account-form-page-section"
160
+ >
161
+ <Title
162
+ headingLevel="h1"
163
+ className="scc-account-form-title"
164
+ ouiaId="scc-account-form-title"
165
+ >
166
+ SUSE Customer Center Account
167
+ </Title>
168
+
169
+ <Form
170
+ isWidthLimited
171
+ className="scc-account-form"
172
+ onSubmit={handleSubmit}
173
+ ouiaId="scc-account-form"
174
+ >
175
+ <Stack hasGutter ouiaId="scc-account-form-stack">
176
+ <StackItem ouiaId="scc-credentials-stack-item">
177
+ <SCCCredentialsCard
178
+ name={name}
179
+ login={login}
180
+ password={password}
181
+ baseUrl={baseUrl}
182
+ testing={testing}
183
+ onNameChange={setName}
184
+ onLoginChange={setLogin}
185
+ onPasswordChange={setPassword}
186
+ onBaseUrlChange={handleUrl}
187
+ onTestConnection={handleTestConnection}
188
+ />
189
+ </StackItem>
190
+
191
+ <StackItem ouiaId="scc-token-refresh-stack-item">
192
+ <SCCTokenRefreshCard
193
+ interval={interval}
194
+ intervalOptions={intervalOptions}
195
+ openInterval={openInterval}
196
+ refreshDate={refreshDate}
197
+ refreshTime={refreshTime}
198
+ onIntervalChange={setInterval}
199
+ onIntervalOpenChange={setOpenInterval}
200
+ onRefreshDateChange={setRefreshDate}
201
+ onRefreshTimeChange={setRefreshTime}
202
+ />
203
+ </StackItem>
204
+
205
+ <StackItem ouiaId="scc-sync-settings-stack-item">
206
+ <SCCSyncSettingsCard
207
+ gpgKey={gpgKey}
208
+ gpgKeyOptions={gpgKeyOptions}
209
+ openGpg={openGpg}
210
+ downloadPolicy={downloadPolicy}
211
+ downloadPolicyOptions={downloadPolicyOptions}
212
+ openDownload={openDownload}
213
+ mirroringPolicy={mirroringPolicy}
214
+ mirroringPolicyOptions={mirroringPolicyOptions}
215
+ openMirroring={openMirroring}
216
+ onGpgKeyChange={setGpgKey}
217
+ onGpgOpenChange={setOpenGpg}
218
+ onDownloadPolicyChange={setDownloadPolicy}
219
+ onDownloadOpenChange={setOpenDownload}
220
+ onMirroringPolicyChange={setMirroringPolicy}
221
+ onMirroringOpenChange={setOpenMirroring}
222
+ />
223
+ </StackItem>
224
+
225
+ <StackItem ouiaId="scc-form-actions-stack-item">
226
+ <Button
227
+ variant="primary"
228
+ type="submit"
229
+ isDisabled={submitting}
230
+ ouiaId="scc-form-submit-button"
231
+ >
232
+ {__('Submit')}
233
+ </Button>{' '}
234
+ <Button
235
+ variant="link"
236
+ component="a"
237
+ href={foremanUrl('/scc_accounts')}
238
+ isDisabled={submitting}
239
+ ouiaId="scc-form-cancel-button"
240
+ >
241
+ {__('Cancel')}
242
+ </Button>
243
+ </StackItem>
244
+ </Stack>
245
+ </Form>
246
+ </PageSection>
247
+ );
248
+ };
249
+
250
+ SCCAccountForm.propTypes = {
251
+ organizationId: PropTypes.number.isRequired,
252
+ intervalOptions: PropTypes.arrayOf(PropTypes.string),
253
+ downloadPolicyOptions: PropTypes.arrayOf(
254
+ PropTypes.oneOfType([
255
+ PropTypes.exact({
256
+ label: PropTypes.string.isRequired,
257
+ value: PropTypes.string.isRequired,
258
+ }),
259
+ PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string])),
260
+ ])
261
+ ),
262
+ mirroringPolicyOptions: PropTypes.arrayOf(
263
+ PropTypes.oneOfType([
264
+ PropTypes.exact({
265
+ label: PropTypes.string.isRequired,
266
+ value: PropTypes.string.isRequired,
267
+ }),
268
+ PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string])),
269
+ ])
270
+ ),
271
+ gpgKeyOptions: PropTypes.arrayOf(
272
+ PropTypes.arrayOf(
273
+ PropTypes.oneOfType([
274
+ PropTypes.string,
275
+ PropTypes.number,
276
+ PropTypes.oneOf([null]),
277
+ ])
278
+ )
279
+ ),
280
+ selectedGpgKeyId: PropTypes.oneOfType([
281
+ PropTypes.number,
282
+ PropTypes.oneOf(['', null]),
283
+ ]),
284
+ initial: PropTypes.shape({
285
+ id: PropTypes.number,
286
+ name: PropTypes.string,
287
+ login: PropTypes.string,
288
+ baseUrl: PropTypes.string,
289
+ interval: PropTypes.string,
290
+ downloadPolicy: PropTypes.string,
291
+ mirroringPolicy: PropTypes.string,
292
+ gpgKey: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['None'])]),
293
+ refreshDate: PropTypes.string,
294
+ refreshTime: PropTypes.string,
295
+ }),
296
+ };
297
+
298
+ SCCAccountForm.defaultProps = {
299
+ intervalOptions: [],
300
+ downloadPolicyOptions: [],
301
+ mirroringPolicyOptions: [],
302
+ gpgKeyOptions: [],
303
+ selectedGpgKeyId: '',
304
+ initial: {},
305
+ };
306
+ export default SCCAccountForm;
@@ -0,0 +1,26 @@
1
+ .scc-account-add-container {
2
+ display: flex;
3
+ justify-content: flex-end;
4
+ margin-top: -8px;
5
+ margin-bottom: 8px;
6
+ }
7
+
8
+ .scc-account-delete-warning {
9
+ background-color: #f9f9f9;
10
+ border-radius: 4px;
11
+ padding: 12px 16px;
12
+ white-space: pre-wrap;
13
+ overflow-x: auto;
14
+ font-size: 0.9rem;
15
+ max-height: 300px;
16
+ }
17
+
18
+ .scc-account-actions {
19
+ display: flex;
20
+ gap: 8px;
21
+ align-items: center;
22
+ }
23
+
24
+ .pf-v5-c-page__main-section.pf-m-light {
25
+ margin-top: -1rem;
26
+ }
@@ -0,0 +1,291 @@
1
+ /* eslint-disable react/prop-types, global-require */
2
+ import React from 'react';
3
+ import '@testing-library/jest-dom';
4
+ import {
5
+ render,
6
+ screen,
7
+ within,
8
+ fireEvent,
9
+ waitFor,
10
+ } from '@testing-library/react';
11
+
12
+ import SccAccountsIndex from './index';
13
+ import { deleteSccAccountAction } from './SCCAccountIndexActions';
14
+
15
+ jest.mock('foremanReact/common/I18n', () => ({ translate: (s) => s }), {
16
+ virtual: true,
17
+ });
18
+ jest.mock('foremanReact/common/helpers', () => ({ foremanUrl: (p) => p }), {
19
+ virtual: true,
20
+ });
21
+ jest.mock('react-redux', () => ({ useDispatch: () => jest.fn() }), {
22
+ virtual: true,
23
+ });
24
+
25
+ jest.mock(
26
+ './SCCAccountIndexActions',
27
+ () => ({
28
+ deleteSccAccountAction: jest.fn(),
29
+ }),
30
+ { virtual: true }
31
+ );
32
+
33
+ jest.mock(
34
+ '@patternfly/react-core',
35
+ () => {
36
+ const { forwardRef } = require('react');
37
+ const PageSection = ({ children }) => <section>{children}</section>;
38
+
39
+ const Button = ({
40
+ children,
41
+ onClick,
42
+ component,
43
+ href,
44
+ 'aria-label': ariaLabel,
45
+ }) => {
46
+ if (component === 'a') {
47
+ return (
48
+ <a href={href} onClick={onClick} aria-label={ariaLabel}>
49
+ {children}
50
+ </a>
51
+ );
52
+ }
53
+ return (
54
+ <button type="button" onClick={onClick} aria-label={ariaLabel}>
55
+ {children}
56
+ </button>
57
+ );
58
+ };
59
+
60
+ const Dropdown = ({ isOpen, onSelect, onOpenChange, toggle, children }) => (
61
+ <div>
62
+ {typeof toggle === 'function' ? toggle({ current: null }) : null}
63
+ {isOpen ? (
64
+ <div data-testid="menu">
65
+ {children}
66
+ <button
67
+ type="button"
68
+ aria-label="Close menu"
69
+ onClick={() => {
70
+ if (onSelect) onSelect();
71
+ if (onOpenChange) onOpenChange(false);
72
+ }}
73
+ />
74
+ </div>
75
+ ) : null}
76
+ </div>
77
+ );
78
+
79
+ const DropdownList = ({ children }) => <div role="menu">{children}</div>;
80
+
81
+ const DropdownItem = ({ children, onClick, isDisabled }) => (
82
+ <div
83
+ role="menuitem"
84
+ aria-disabled={!!isDisabled}
85
+ onClick={(e) => {
86
+ if (!isDisabled && onClick) onClick(e);
87
+ }}
88
+ >
89
+ {children}
90
+ </div>
91
+ );
92
+
93
+ const MenuToggle = forwardRef(
94
+ ({ children, onClick, 'aria-label': ariaLabel }, ref) => (
95
+ <button
96
+ ref={ref}
97
+ type="button"
98
+ aria-label={ariaLabel}
99
+ onClick={onClick}
100
+ >
101
+ {children}
102
+ </button>
103
+ )
104
+ );
105
+
106
+ const Modal = ({ title, isOpen, children, actions }) =>
107
+ isOpen ? (
108
+ <div role="dialog" aria-label={title}>
109
+ <h2>{title}</h2>
110
+ {children}
111
+ {Array.isArray(actions) &&
112
+ actions.map((node, i) => <div key={i}>{node}</div>)}
113
+ </div>
114
+ ) : null;
115
+
116
+ return {
117
+ __esModule: true,
118
+ PageSection,
119
+ Button,
120
+ Dropdown,
121
+ DropdownList,
122
+ DropdownItem,
123
+ MenuToggle,
124
+ Modal,
125
+ };
126
+ },
127
+ { virtual: true }
128
+ );
129
+
130
+ jest.mock(
131
+ '@patternfly/react-table',
132
+ () => ({
133
+ __esModule: true,
134
+ Table: ({ children, ..._rest }) => <table>{children}</table>,
135
+ Thead: ({ children }) => <thead>{children}</thead>,
136
+ Tbody: ({ children }) => <tbody>{children}</tbody>,
137
+ Tr: ({ children }) => <tr>{children}</tr>,
138
+ Th: ({ children }) => <th>{children}</th>,
139
+ Td: ({ children }) => <td>{children}</td>,
140
+ }),
141
+ { virtual: true }
142
+ );
143
+
144
+ // Helpers
145
+ const renderComponent = (initialAccounts = []) =>
146
+ render(<SccAccountsIndex initialAccounts={initialAccounts} />);
147
+
148
+ const openActionsMenu = () => {
149
+ fireEvent.click(screen.getByLabelText('Actions menu'));
150
+ return screen.getByTestId('menu');
151
+ };
152
+
153
+ const getRowByAccountName = (name) =>
154
+ screen.getByRole('row', { name: new RegExp(name, 'i') });
155
+
156
+ // Tests
157
+ describe('SccAccountsIndex', () => {
158
+ beforeEach(() => {
159
+ jest.clearAllMocks();
160
+ });
161
+
162
+ it('renders without crashing and shows the Add button', () => {
163
+ renderComponent();
164
+ expect(screen.getByText('Add SCC account')).toBeInTheDocument();
165
+ });
166
+
167
+ it('shows expected table headers', () => {
168
+ renderComponent();
169
+ ['Name', 'Products', 'Last synced', 'Actions'].forEach((h) => {
170
+ expect(screen.getByText(h)).toBeInTheDocument();
171
+ });
172
+ });
173
+
174
+ it('renders rows from props (names + product counts) with edit links', () => {
175
+ const data = [
176
+ { id: 1, name: 'Acc A', scc_products_with_repos_count: 2 },
177
+ { id: 2, name: 'Acc B', scc_products_with_repos_count: 5 },
178
+ ];
179
+ renderComponent(data);
180
+
181
+ expect(screen.getByRole('link', { name: 'Acc A' })).toHaveAttribute(
182
+ 'href',
183
+ '/scc_accounts/1/edit'
184
+ );
185
+ expect(screen.getByRole('link', { name: 'Acc B' })).toHaveAttribute(
186
+ 'href',
187
+ '/scc_accounts/2/edit'
188
+ );
189
+
190
+ const rowA = getRowByAccountName('Acc A');
191
+ const rowB = getRowByAccountName('Acc B');
192
+ expect(within(rowA).getByText('2')).toBeInTheDocument();
193
+ expect(within(rowB).getByText('5')).toBeInTheDocument();
194
+ });
195
+
196
+ it('shows correct "Last synced" content: link when task exists, "never synced" otherwise', () => {
197
+ const data = [
198
+ {
199
+ id: 10,
200
+ name: 'Successful Sync',
201
+ scc_products_with_repos_count: 1,
202
+ sync_status: 'success',
203
+ sync_task: { id: 'task-123' },
204
+ },
205
+ {
206
+ id: 11,
207
+ name: 'Failed Sync',
208
+ scc_products_with_repos_count: 0,
209
+ sync_status: 'error',
210
+ sync_task: { id: 'task-456' },
211
+ },
212
+ {
213
+ id: 12,
214
+ name: 'No Sync',
215
+ scc_products_with_repos_count: 0,
216
+ sync_status: null,
217
+ },
218
+ ];
219
+ renderComponent(data);
220
+
221
+ const successRow = getRowByAccountName('Successful Sync');
222
+ expect(
223
+ within(successRow).getByRole('link', { name: 'success' })
224
+ ).toHaveAttribute('href', '/foreman_tasks/tasks/task-123');
225
+
226
+ const errorRow = getRowByAccountName('Failed Sync');
227
+ expect(
228
+ within(errorRow).getByRole('link', { name: 'error' })
229
+ ).toHaveAttribute('href', '/foreman_tasks/tasks/task-456');
230
+
231
+ const noSyncRow = getRowByAccountName('No Sync');
232
+ expect(within(noSyncRow).getByText('never synced')).toBeInTheDocument();
233
+ });
234
+
235
+ it('delete flow: open modal, confirm calls action, cancel closes modal', async () => {
236
+ const data = [
237
+ {
238
+ id: 5,
239
+ name: 'ToDelete',
240
+ scc_products_with_repos_count: 0,
241
+ sync_status: 'never synced',
242
+ },
243
+ ];
244
+ renderComponent(data);
245
+
246
+ const menu = openActionsMenu();
247
+ fireEvent.click(within(menu).getByRole('menuitem', { name: 'Delete' }));
248
+
249
+ const dialog = screen.getByRole('dialog');
250
+ fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
251
+
252
+ expect(deleteSccAccountAction).toHaveBeenCalledWith(
253
+ expect.any(Function), // dispatch
254
+ 5, // accountId
255
+ expect.any(Function), // setAccounts
256
+ expect.any(Function), // setDeleteOpen
257
+ expect.any(Object) // deletingIdRef
258
+ );
259
+
260
+ fireEvent.click(
261
+ within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })
262
+ );
263
+ await waitFor(() =>
264
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
265
+ );
266
+
267
+ const menu2 = openActionsMenu();
268
+ fireEvent.click(within(menu2).getByRole('menuitem', { name: 'Delete' }));
269
+ fireEvent.click(
270
+ within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' })
271
+ );
272
+ await waitFor(() =>
273
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
274
+ );
275
+ });
276
+
277
+ it('empty state: renders headers and no data rows', () => {
278
+ renderComponent([]);
279
+
280
+ ['Name', 'Products', 'Last synced', 'Actions'].forEach((h) => {
281
+ expect(screen.getByText(h)).toBeInTheDocument();
282
+ });
283
+
284
+ expect(screen.getAllByRole('row')).toHaveLength(1);
285
+
286
+ expect(screen.queryByLabelText('Actions menu')).not.toBeInTheDocument();
287
+ expect(
288
+ screen.queryByRole('button', { name: 'Select Products' })
289
+ ).not.toBeInTheDocument();
290
+ });
291
+ });