Add custom repository management for cockpit-image-builder
This PR adds custom repository management functionality to cockpit-image-builder, reintroducing 'sources' functionality that was available in cockpit-composer. Changes: - Add ManageRepositoriesButton component for on-premise/hosted environments - Add ManageRepositoriesModal for custom repository creation - Extend cockpit contentSourcesApi with repository endpoints - Add environment-specific API hooks in contentSourcesApi - Include comprehensive test coverage for new components
This commit is contained in:
parent
54e413f459
commit
2adf5197fe
6 changed files with 494 additions and 6 deletions
|
|
@ -1,11 +1,35 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
|
||||
import { ExternalLinkAltIcon, PlusIcon } from '@patternfly/react-icons';
|
||||
|
||||
import ManageRepositoriesModal from './ManageRepositoriesModal';
|
||||
|
||||
import { CONTENT_URL } from '../../../../../constants';
|
||||
|
||||
const ManageRepositoriesButton = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (process.env.IS_ON_PREMISE) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant='link'
|
||||
iconPosition='right'
|
||||
isInline
|
||||
icon={<PlusIcon />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
Add custom repository
|
||||
</Button>
|
||||
<ManageRepositoriesModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
component='a'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
FormGroup,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Tab,
|
||||
Tabs,
|
||||
TabTitleText,
|
||||
TextArea,
|
||||
TextInput,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import { useCreateRepositoryMutation } from '../../../../../store/contentSourcesApi';
|
||||
|
||||
interface ManageRepositoriesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ManageRepositoriesModal: React.FC<ManageRepositoriesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [gpgKey, setGpgKey] = useState('');
|
||||
const [metadataVerification, setMetadataVerification] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [createRepository, { isLoading }] = useCreateRepositoryMutation();
|
||||
|
||||
const handleTabClick = (
|
||||
_event: React.MouseEvent<HTMLElement> | React.KeyboardEvent | MouseEvent,
|
||||
tabIndex: string | number,
|
||||
) => {
|
||||
setActiveTabKey(tabIndex);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setUrl('');
|
||||
setGpgKey('');
|
||||
setMetadataVerification(true);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
|
||||
if (!name.trim() || !url.trim()) {
|
||||
setError('Name and URL are required fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRepository({
|
||||
apiRepositoryRequest: {
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
gpg_key: gpgKey.trim() || undefined,
|
||||
metadata_verification: metadataVerification,
|
||||
},
|
||||
}).unwrap();
|
||||
|
||||
resetForm();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
(err as { data?: { message?: string } })?.data?.message ||
|
||||
'Failed to create repository. Please try again.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title='Manage Custom Repositories'
|
||||
variant='medium'
|
||||
>
|
||||
<ModalHeader title='Manage Custom Repositories' />
|
||||
<ModalBody>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onSelect={handleTabClick}
|
||||
aria-label='Repository management tabs'
|
||||
>
|
||||
<Tab
|
||||
eventKey={0}
|
||||
title={<TabTitleText>Add Repository</TabTitleText>}
|
||||
aria-label='Add repository tab'
|
||||
>
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
{error && (
|
||||
<Alert variant='danger' isInline title='Error'>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Form>
|
||||
<FormGroup
|
||||
label='Repository Name'
|
||||
isRequired
|
||||
fieldId='repo-name'
|
||||
>
|
||||
<TextInput
|
||||
isRequired
|
||||
type='text'
|
||||
id='repo-name'
|
||||
name='repo-name'
|
||||
value={name}
|
||||
onChange={(_, value) => setName(value)}
|
||||
placeholder='Enter repository name'
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label='Repository URL' isRequired fieldId='repo-url'>
|
||||
<TextInput
|
||||
isRequired
|
||||
type='url'
|
||||
id='repo-url'
|
||||
name='repo-url'
|
||||
value={url}
|
||||
onChange={(_, value) => setUrl(value)}
|
||||
placeholder='https://example.com/repo/'
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup label='GPG Key' fieldId='repo-gpg-key'>
|
||||
<TextArea
|
||||
id='repo-gpg-key'
|
||||
name='repo-gpg-key'
|
||||
value={gpgKey}
|
||||
onChange={(_, value) => setGpgKey(value)}
|
||||
placeholder='-----BEGIN PGP PUBLIC KEY BLOCK-----'
|
||||
rows={8}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
key='create'
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
isDisabled={!name.trim() || !url.trim()}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Repository'}
|
||||
</Button>
|
||||
<Button key='cancel' variant='link' onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageRepositoriesModal;
|
||||
|
|
@ -2,6 +2,11 @@ import { emptyCockpitApi } from './emptyCockpitApi';
|
|||
import type { Package, SearchRpmApiArg } from './types';
|
||||
|
||||
import type {
|
||||
ApiRepositoryRequest,
|
||||
CreateRepositoryApiArg,
|
||||
CreateRepositoryApiResponse,
|
||||
ListRepositoriesApiArg,
|
||||
ListRepositoriesApiResponse,
|
||||
ListSnapshotsByDateApiArg,
|
||||
ListSnapshotsByDateApiResponse,
|
||||
SearchRpmApiResponse,
|
||||
|
|
@ -62,9 +67,60 @@ export const contentSourcesApi = emptyCockpitApi.injectEndpoints({
|
|||
},
|
||||
}),
|
||||
}),
|
||||
listRepositories: builder.query<
|
||||
ListRepositoriesApiResponse,
|
||||
ListRepositoriesApiArg
|
||||
>({
|
||||
queryFn: async (queryArgs, _, __, baseQuery) => {
|
||||
const result = await baseQuery({
|
||||
url: '/repositories',
|
||||
method: 'GET',
|
||||
params: {
|
||||
limit: queryArgs.limit,
|
||||
offset: queryArgs.offset,
|
||||
search: queryArgs.search,
|
||||
origin: queryArgs.origin,
|
||||
content_type: queryArgs.contentType,
|
||||
available_for_arch: queryArgs.availableForArch,
|
||||
available_for_version: queryArgs.availableForVersion,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
return { data: result.data };
|
||||
},
|
||||
}),
|
||||
createRepository: builder.mutation<
|
||||
CreateRepositoryApiResponse,
|
||||
CreateRepositoryApiArg
|
||||
>({
|
||||
queryFn: async (queryArgs, _, __, baseQuery) => {
|
||||
const result = await baseQuery({
|
||||
url: '/repositories',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(queryArgs.apiRepositoryRequest),
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
return { data: result.data };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useSearchRpmMutation, useListSnapshotsByDateMutation } =
|
||||
contentSourcesApi;
|
||||
export const {
|
||||
useSearchRpmMutation,
|
||||
useListSnapshotsByDateMutation,
|
||||
useListRepositoriesQuery,
|
||||
useCreateRepositoryMutation,
|
||||
} = contentSourcesApi;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@ export const useListSnapshotsByDateMutation = process.env.IS_ON_PREMISE
|
|||
? cockpitQueries.useListSnapshotsByDateMutation
|
||||
: serviceQueries.useListSnapshotsByDateMutation;
|
||||
|
||||
export const useCreateRepositoryMutation = process.env.IS_ON_PREMISE
|
||||
? cockpitQueries.useCreateRepositoryMutation
|
||||
: serviceQueries.useCreateRepositoryMutation;
|
||||
|
||||
export const useListRepositoriesQuery = process.env.IS_ON_PREMISE
|
||||
? cockpitQueries.useListRepositoriesQuery
|
||||
: serviceQueries.useListRepositoriesQuery;
|
||||
|
||||
export const {
|
||||
useListFeaturesQuery,
|
||||
useSearchPackageGroupMutation,
|
||||
useListRepositoriesQuery,
|
||||
useCreateRepositoryMutation,
|
||||
useBulkImportRepositoriesMutation,
|
||||
useListRepositoriesRpmsQuery,
|
||||
useListRepositoryParametersQuery,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import React from 'react';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import ManageRepositoriesButton from '../../../../../Components/CreateImageWizard/steps/Repositories/components/ManageRepositoriesButton';
|
||||
import {
|
||||
onPremMiddleware,
|
||||
onPremReducer,
|
||||
serviceMiddleware,
|
||||
serviceReducer,
|
||||
} from '../../../../../store';
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env;
|
||||
|
||||
const createTestStore = () => {
|
||||
const mw = process.env.IS_ON_PREMISE ? onPremMiddleware : serviceMiddleware;
|
||||
const red = process.env.IS_ON_PREMISE ? onPremReducer : serviceReducer;
|
||||
return configureStore({
|
||||
reducer: red,
|
||||
middleware: mw,
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithStore = (component: React.ReactElement) => {
|
||||
const store = createTestStore();
|
||||
return render(<Provider store={store}>{component}</Provider>);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('ManageRepositoriesButton', () => {
|
||||
test('renders external link for hosted service', () => {
|
||||
process.env.IS_ON_PREMISE = undefined;
|
||||
renderWithStore(<ManageRepositoriesButton />);
|
||||
|
||||
const externalLink = screen.getByRole('link', {
|
||||
name: /create and manage repositories here/i,
|
||||
});
|
||||
expect(externalLink).toBeInTheDocument();
|
||||
expect(externalLink).toHaveAttribute(
|
||||
'href',
|
||||
'/insights/content/repositories',
|
||||
);
|
||||
expect(externalLink).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
test('renders modal button for on-premise', async () => {
|
||||
process.env.IS_ON_PREMISE = 'true';
|
||||
renderWithStore(<ManageRepositoriesButton />);
|
||||
|
||||
const modalButton = screen.getByRole('button', {
|
||||
name: /add custom repository/i,
|
||||
});
|
||||
expect(modalButton).toBeInTheDocument();
|
||||
|
||||
// Click the button to open modal
|
||||
await user.click(modalButton);
|
||||
|
||||
// Check that modal opens
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /manage custom repositories/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show modal initially', () => {
|
||||
process.env.IS_ON_PREMISE = 'true';
|
||||
renderWithStore(<ManageRepositoriesButton />);
|
||||
|
||||
// Modal should not be visible initially
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /manage custom repositories/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import React from 'react';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import ManageRepositoriesModal from '../../../../../Components/CreateImageWizard/steps/Repositories/components/ManageRepositoriesModal';
|
||||
import { serviceMiddleware, serviceReducer } from '../../../../../store';
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockCreateRepository = vi.fn();
|
||||
|
||||
// Mock the hook
|
||||
vi.mock('../../../../../store/contentSourcesApi', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useCreateRepositoryMutation: () => [
|
||||
mockCreateRepository,
|
||||
{ isLoading: false },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const createTestStore = () => {
|
||||
// For this test, we'll use the service store since the mock is already set up for service API
|
||||
return configureStore({
|
||||
reducer: serviceReducer,
|
||||
middleware: serviceMiddleware,
|
||||
});
|
||||
};
|
||||
|
||||
const setup = (isOpen = true) => {
|
||||
const onClose = vi.fn();
|
||||
const store = createTestStore();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ManageRepositoriesModal isOpen={isOpen} onClose={onClose} />
|
||||
</Provider>,
|
||||
);
|
||||
return { onClose };
|
||||
};
|
||||
|
||||
describe('ManageRepositoriesModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders modal when open', () => {
|
||||
setup();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /manage custom repositories/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Repository')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Enter repository name'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('https://example.com/repo/'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('-----BEGIN PGP PUBLIC KEY BLOCK-----'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render modal when closed', () => {
|
||||
setup(false);
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /manage custom repositories/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('validates required fields', async () => {
|
||||
setup();
|
||||
const createButton = screen.getByRole('button', {
|
||||
name: /create repository/i,
|
||||
});
|
||||
|
||||
// Button should be disabled when fields are empty
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
// Fill name but not URL
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Enter repository name'),
|
||||
'Test Repo',
|
||||
);
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
// Fill URL
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('https://example.com/repo/'),
|
||||
'https://example.com/repo',
|
||||
);
|
||||
expect(createButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('keeps button disabled with whitespace-only fields', async () => {
|
||||
setup();
|
||||
const nameInput = screen.getByPlaceholderText('Enter repository name');
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/repo/');
|
||||
const createButton = screen.getByRole('button', {
|
||||
name: /create repository/i,
|
||||
});
|
||||
|
||||
// Initially disabled
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
// Fill with whitespace only - button should remain disabled
|
||||
await user.type(nameInput, ' ');
|
||||
await user.type(urlInput, ' ');
|
||||
|
||||
// Button should still be disabled since trim() makes them empty
|
||||
expect(createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('calls onClose when cancel is clicked', async () => {
|
||||
const { onClose } = setup();
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
|
||||
await user.click(cancelButton);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('fills form fields correctly', async () => {
|
||||
setup();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter repository name');
|
||||
const urlInput = screen.getByPlaceholderText('https://example.com/repo/');
|
||||
const gpgKeyInput = screen.getByPlaceholderText(
|
||||
'-----BEGIN PGP PUBLIC KEY BLOCK-----',
|
||||
);
|
||||
|
||||
await user.type(nameInput, 'Test Repository');
|
||||
await user.type(urlInput, 'https://example.com/repo/');
|
||||
await user.type(gpgKeyInput, '-----BEGIN PGP PUBLIC KEY BLOCK-----');
|
||||
|
||||
expect(nameInput).toHaveValue('Test Repository');
|
||||
expect(urlInput).toHaveValue('https://example.com/repo/');
|
||||
expect(gpgKeyInput).toHaveValue('-----BEGIN PGP PUBLIC KEY BLOCK-----');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue