mirror of
https://github.com/portainer/portainer.git
synced 2025-08-04 13:25:26 +02:00
feat(helm): show manifest previews/changes when installing and upgrading a helm chart [r8s-405] (#898)
This commit is contained in:
parent
a4cff13531
commit
60bc04bc33
41 changed files with 763 additions and 157 deletions
|
@ -0,0 +1,197 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { ManifestPreviewFormSection } from './ManifestPreviewFormSection';
|
||||
|
||||
// Mock the necessary hooks
|
||||
const mockUseHelmDryRun = vi.fn();
|
||||
const mockUseDebouncedValue = vi.fn();
|
||||
|
||||
vi.mock('../helmReleaseQueries/useHelmDryRun', () => ({
|
||||
useHelmDryRun: (...args: unknown[]) => mockUseHelmDryRun(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useDebouncedValue', () => ({
|
||||
useDebouncedValue: (value: unknown, delay: number) =>
|
||||
mockUseDebouncedValue(value, delay),
|
||||
}));
|
||||
|
||||
// Mock the CodeEditor and DiffViewer components
|
||||
vi.mock('@@/CodeEditor', () => ({
|
||||
CodeEditor: ({
|
||||
'data-cy': dataCy,
|
||||
value,
|
||||
}: {
|
||||
'data-cy'?: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<div data-cy={dataCy} data-testid="code-editor">
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@@/CodeEditor/DiffViewer', () => ({
|
||||
DiffViewer: ({
|
||||
'data-cy': dataCy,
|
||||
originalCode,
|
||||
newCode,
|
||||
}: {
|
||||
'data-cy'?: string;
|
||||
originalCode: string;
|
||||
newCode: string;
|
||||
}) => (
|
||||
<div data-cy={dataCy} data-testid="diff-viewer">
|
||||
<div data-testid="original-code">{originalCode}</div>
|
||||
<div data-testid="new-code">{newCode}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockOnChangePreviewValidation = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
payload: {
|
||||
name: 'test-release',
|
||||
namespace: 'test-namespace',
|
||||
chart: 'test-chart',
|
||||
version: '1.0.0',
|
||||
repo: 'test-repo',
|
||||
},
|
||||
onChangePreviewValidation: mockOnChangePreviewValidation,
|
||||
title: 'Manifest Preview',
|
||||
environmentId: 1,
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const user = new UserViewModel({ Username: 'user', Role: 1 });
|
||||
|
||||
const Component = withTestQueryProvider(
|
||||
withUserProvider(
|
||||
withTestRouter(() => (
|
||||
<ManifestPreviewFormSection {...defaultProps} {...props} />
|
||||
)),
|
||||
user
|
||||
)
|
||||
);
|
||||
|
||||
return render(<Component />);
|
||||
}
|
||||
|
||||
describe('ManifestPreviewFormSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock for useDebouncedValue - returns the value as-is
|
||||
mockUseDebouncedValue.mockImplementation((value) => value);
|
||||
});
|
||||
|
||||
it('should show loading and no form section when loading', () => {
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByText('Generating manifest preview...')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error and no form section when error', () => {
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: false,
|
||||
isError: true,
|
||||
error: { message: 'Invalid chart configuration' },
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
screen.getByText('Error with Helm chart configuration')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid chart configuration')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Manifest Preview')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show single code editor when only the generated manifest is available', async () => {
|
||||
const mockManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: test';
|
||||
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
data: { manifest: mockManifest },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
|
||||
|
||||
// Expand the FormSection to see the content
|
||||
const expandButton = screen.getByLabelText('Expand');
|
||||
await userEvent.click(expandButton);
|
||||
|
||||
// Check that the manifest content is rendered (from the HTML, we can see it's there)
|
||||
expect(
|
||||
screen.getByText(/apiVersion/, { exact: false })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/test/, { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the diff when the current and generated manifest are available', async () => {
|
||||
const currentManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: old';
|
||||
const newManifest = 'apiVersion: v1\nkind: Pod\nmetadata:\n name: new';
|
||||
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
data: { manifest: newManifest },
|
||||
});
|
||||
|
||||
renderComponent({ currentManifest });
|
||||
|
||||
expect(screen.getByText('Manifest Preview')).toBeInTheDocument();
|
||||
|
||||
// Expand the FormSection to see the content
|
||||
const expandButton = screen.getByLabelText('Expand');
|
||||
await userEvent.click(expandButton);
|
||||
|
||||
// Check that both old and new manifest content is rendered
|
||||
expect(screen.getByText(/old/, { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByText(/new/, { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChangePreviewValidation with correct validation state', () => {
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
data: { manifest: 'test' },
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onChangePreviewValidation with false when error occurs', () => {
|
||||
mockUseHelmDryRun.mockReturnValue({
|
||||
isInitialLoading: false,
|
||||
isError: true,
|
||||
error: { message: 'Error' },
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(mockOnChangePreviewValidation).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,109 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useDebouncedValue } from '@/react/hooks/useDebouncedValue';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { CodeEditor } from '@@/CodeEditor';
|
||||
import { DiffViewer } from '@@/CodeEditor/DiffViewer';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Alert } from '@@/Alert';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useHelmDryRun } from '../helmReleaseQueries/useHelmDryRun';
|
||||
import { UpdateHelmReleasePayload } from '../types';
|
||||
|
||||
type Props = {
|
||||
payload: UpdateHelmReleasePayload;
|
||||
onChangePreviewValidation: (isValid: boolean) => void;
|
||||
currentManifest?: string; // only true on upgrade, not install
|
||||
title: string;
|
||||
environmentId: EnvironmentId;
|
||||
};
|
||||
|
||||
export function ManifestPreviewFormSection({
|
||||
payload,
|
||||
currentManifest,
|
||||
onChangePreviewValidation,
|
||||
title,
|
||||
environmentId,
|
||||
}: Props) {
|
||||
const debouncedPayload = useDebouncedValue(payload, 500);
|
||||
const manifestPreviewQuery = useHelmDryRun(environmentId, debouncedPayload);
|
||||
const [isFolded, setIsFolded] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
onChangePreviewValidation(!manifestPreviewQuery.isError);
|
||||
}, [manifestPreviewQuery.isError, onChangePreviewValidation]);
|
||||
|
||||
if (
|
||||
!debouncedPayload.name ||
|
||||
!debouncedPayload.namespace ||
|
||||
!debouncedPayload.chart
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// only show loading state or the error to keep the view simple (omitting the preview section because there is nothing to preview)
|
||||
if (manifestPreviewQuery.isInitialLoading) {
|
||||
return <InlineLoader>Generating manifest preview...</InlineLoader>;
|
||||
}
|
||||
|
||||
if (manifestPreviewQuery.isError) {
|
||||
return (
|
||||
<Alert color="error" title="Error with Helm chart configuration">
|
||||
{manifestPreviewQuery.error?.message ||
|
||||
'Error generating manifest preview'}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection
|
||||
title={title}
|
||||
isFoldable
|
||||
defaultFolded={isFolded}
|
||||
setIsDefaultFolded={setIsFolded}
|
||||
>
|
||||
<ManifestPreview
|
||||
currentManifest={currentManifest}
|
||||
newManifest={manifestPreviewQuery.data?.manifest ?? ''}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
|
||||
function ManifestPreview({
|
||||
currentManifest,
|
||||
newManifest,
|
||||
}: {
|
||||
currentManifest?: string;
|
||||
newManifest: string;
|
||||
}) {
|
||||
if (!newManifest) {
|
||||
return <TextTip color="blue">No manifest preview available</TextTip>;
|
||||
}
|
||||
|
||||
if (currentManifest) {
|
||||
return (
|
||||
<DiffViewer
|
||||
originalCode={currentManifest}
|
||||
newCode={newManifest}
|
||||
id="manifest-preview"
|
||||
data-cy="manifest-diff-preview"
|
||||
type="yaml"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeEditor
|
||||
id="manifest-preview"
|
||||
value={newManifest}
|
||||
data-cy="manifest-preview"
|
||||
type="yaml"
|
||||
readonly
|
||||
showToolbar={false}
|
||||
/>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue