refactor(stacks): use formik in StackRedeployGitForm [BE-12430] (#1433)

This commit is contained in:
Chaim Lev-Ari
2025-11-27 08:43:51 +02:00
committed by GitHub
parent 7010d7bf66
commit bf6cb8d0b8
18 changed files with 757 additions and 726 deletions
@@ -0,0 +1,50 @@
import { RefreshCw } from 'lucide-react';
import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons';
interface Props {
isDirty: boolean;
isValid: boolean;
isSaveLoading: boolean;
isDeployLoading: boolean;
onDeploy: () => void;
}
export function ActionsSection({
isDirty,
isValid,
isSaveLoading,
isDeployLoading,
onDeploy,
}: Props) {
return (
<FormSection title="Actions">
<LoadingButton
size="small"
color="primary"
type="button"
onClick={onDeploy}
disabled={isDirty || isSaveLoading}
isLoading={isDeployLoading}
loadingText="In progress..."
data-cy="stack-redeploy-button"
>
<RefreshCw className="mr-1" />
Pull and redeploy
</LoadingButton>
<LoadingButton
size="small"
color="primary"
disabled={!isDirty || !isValid || isDeployLoading}
isLoading={isSaveLoading}
loadingText="In progress..."
className="ml-2"
data-cy="stack-save-settings-button"
>
Save settings
</LoadingButton>
</FormSection>
);
}
@@ -0,0 +1,112 @@
import { MinusIcon, PlusIcon } from 'lucide-react';
import { useReducer } from 'react';
import { useFormikContext } from 'formik';
import { Stack } from '@/react/common/stacks/types';
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { RefField } from '@/react/portainer/gitops/RefField';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { RelativePathModel } from '@/react/portainer/gitops/types';
import { RefFieldModel } from '@/react/portainer/gitops/RefField/types';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
import { FormValues } from './types';
import { TLSVerificationField } from './TLSVerificationField';
interface Props {
stack: Stack;
}
export function AdvancedConfigurationSection({ stack }: Props) {
const { values, setFieldValue, errors, initialValues } =
useFormikContext<FormValues>();
const [isAdvancedMode, toggleAdvancedMode] = useReducer(
(state) => !state,
false
);
const gitConfig = stack.GitConfig;
if (!gitConfig) {
return null;
}
const valuesToPassDownToFields: RefFieldModel = {
RepositoryURL: gitConfig.URL || '',
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
TLSSkipVerify: values.tlsSkipVerify,
};
const relativePathValues: RelativePathModel = {
FilesystemPath: stack.FilesystemPath,
SupportRelativePath: stack.SupportRelativePath,
PerDeviceConfigsGroupMatchType: '',
SupportPerDeviceConfigs: false,
PerDeviceConfigsMatchType: '',
PerDeviceConfigsPath: '',
};
return (
<>
<div className="form-group">
<div className="col-sm-12">
<Button
color="none"
onClick={() => toggleAdvancedMode()}
data-cy="advanced-configuration-toggle-button"
>
<Icon
icon={isAdvancedMode ? MinusIcon : PlusIcon}
className="mr-1"
/>
{isAdvancedMode ? 'Hide' : 'Advanced'} configuration
</Button>
</div>
</div>
{isAdvancedMode && (
<>
<RefField
value={values.refName}
onChange={(value) => setFieldValue('refName', value)}
model={valuesToPassDownToFields}
isUrlValid
stackId={stack.Id}
error={errors.refName}
/>
<AuthFieldset
value={values.auth}
onChange={(value) => {
Object.entries(value).forEach(([key, val]) => {
setFieldValue(`auth.${key}`, val);
});
}}
isAuthExplanationVisible
errors={errors.auth}
/>
<TLSVerificationField
value={values.tlsSkipVerify}
initialValue={initialValues.tlsSkipVerify}
onChange={(value) => setFieldValue('tlsSkipVerify', value)}
/>
<RelativePathFieldset
values={relativePathValues}
gitModel={valuesToPassDownToFields}
isEditing
hideEdgeConfigs
onChange={() => {}}
/>
</>
)}
</>
);
}
@@ -0,0 +1,89 @@
import { Form, useFormikContext } from 'formik';
import { Stack, StackType } from '@/react/common/stacks/types';
import { baseStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { FormSection } from '@@/form-components/FormSection';
import { StackEnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormValues } from './types';
import { AdvancedConfigurationSection } from './AdvancedConfigurationSection';
import { OptionsSection } from './OptionsSection';
import { ActionsSection } from './ActionsSection';
export function InnerForm({
stack,
onDeploy,
webhookId,
isDeployLoading,
isSaveLoading,
}: {
stack: Stack;
webhookId: string;
onDeploy(values: FormValues): Promise<void>;
isSaveLoading: boolean;
isDeployLoading: boolean;
}) {
const envId = useEnvironmentId();
const apiVersion = useApiVersion(envId);
const { values, setFieldValue, errors, dirty, isValid } =
useFormikContext<FormValues>();
const gitConfig = stack.GitConfig;
if (!gitConfig) {
return null;
}
return (
<Form className="form-horizontal my-8">
<FormSection title="Redeploy from git repository">
<InfoPanel
className="text-muted small"
url={gitConfig.URL}
type="stack"
configFilePath={gitConfig.ConfigFilePath}
additionalFiles={stack.AdditionalFiles}
/>
<AutoUpdateFieldset
value={values.autoUpdate}
onChange={(value) => setFieldValue('autoUpdate', value)}
environmentType="DOCKER"
isForcePullVisible={stack.Type !== StackType.Kubernetes}
baseWebhookUrl={baseStackWebhookUrl()}
webhookId={webhookId}
webhooksDocs="/user/docker/stacks/webhooks"
errors={errors.autoUpdate}
/>
<TimeWindowDisplay />
<AdvancedConfigurationSection stack={stack} />
<StackEnvironmentVariablesPanel
values={values.env}
onChange={(value) => setFieldValue('env', value)}
showHelpMessage
isFoldable
errors={errors.env}
/>
<OptionsSection stack={stack} apiVersion={apiVersion} />
<ActionsSection
isDirty={dirty}
isValid={isValid}
isSaveLoading={isSaveLoading}
isDeployLoading={isDeployLoading}
onDeploy={() => onDeploy(values)}
/>
</FormSection>
</Form>
);
}
@@ -0,0 +1,39 @@
import { useFormikContext } from 'formik';
import { Stack, StackType } from '@/react/common/stacks/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormSection } from '@@/form-components/FormSection';
import { FormValues } from './types';
interface Props {
stack: Stack;
apiVersion: number;
}
export function OptionsSection({ stack, apiVersion }: Props) {
const { values, setFieldValue } = useFormikContext<FormValues>();
if (stack.Type !== StackType.DockerSwarm || apiVersion < 1.27) {
return null;
}
return (
<FormSection title="Options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="prune"
checked={values.prune}
tooltip="Prune services that are no longer referenced."
labelClass="col-sm-3 col-lg-2"
label="Prune services"
onChange={(value) => setFieldValue('prune', value)}
data-cy="stack-prune-services-switch"
/>
</div>
</div>
</FormSection>
);
}
@@ -0,0 +1,766 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { HttpResponse } from 'msw';
import _ from 'lodash';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
import {
baseStackWebhookUrl,
createWebhookId,
} from '@/portainer/helpers/webhookHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Stack } from '@/react/common/stacks/types';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { useApiVersion } from '@/react/docker/proxy/queries/useVersion';
import { http, server } from '@/setup-tests/server';
import { StackRedeployGitForm } from './StackRedeployGitForm';
type StackRedeployGitFormProps = React.ComponentProps<
typeof StackRedeployGitForm
>;
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useRouter: vi.fn(() => ({
stateService: {
reload: vi.fn(),
},
})),
}));
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
confirmStackUpdate: vi.fn(),
}));
vi.mock('@/react/portainer/gitops/utils', () => ({
confirmEnableTLSVerify: vi.fn(),
}));
vi.mock('@/portainer/helpers/webhookHelper', () => ({
baseStackWebhookUrl: vi.fn(),
createWebhookId: vi.fn(),
}));
vi.mock('@/react/portainer/gitops/AutoUpdateFieldset/utils', () => ({
parseAutoUpdateResponse: vi.fn(() => ({
RepositoryAutomaticUpdates: true,
RepositoryMechanism: 'Webhook',
RepositoryFetchInterval: '5m',
ForcePullImage: false,
RepositoryAutomaticUpdatesForce: false,
})),
transformAutoUpdateViewModel: vi.fn(
(_viewModel: unknown, webhookId: string) => ({
Interval: '',
Webhook: webhookId,
ForceUpdate: false,
ForcePullImage: false,
})
),
}));
// Mock router hooks
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: vi.fn(() => 1),
}));
vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
useCurrentEnvironment: vi.fn(() => ({ Id: 1, Name: 'test' })),
}));
// Mock components that require router context
vi.mock('@/react/portainer/gitops/TimeWindowDisplay', () => ({
TimeWindowDisplay: vi.fn(() => (
<div data-testid="time-window-display">Time Window Display</div>
)),
}));
vi.mock(
'@/react/components/form-components/EnvironmentVariablesFieldset/StackEnvironmentVariablesPanel',
() => ({
StackEnvironmentVariablesPanel: vi.fn(() => (
<div data-testid="environment-variables-panel">
Environment Variables Panel
</div>
)),
})
);
vi.mock('@/react/portainer/gitops/InfoPanel', () => ({
InfoPanel: vi.fn(({ url, configFilePath }) => (
<div data-testid="info-panel">
<span>{url}</span>
<span>{configFilePath}</span>
</div>
)),
}));
vi.mock('@/react/portainer/gitops/AutoUpdateFieldset', () => ({
AutoUpdateFieldset: vi.fn(() => (
<div data-testid="auto-update-fieldset">Auto Update Fieldset</div>
)),
}));
vi.mock('@/react/portainer/gitops/RefField', () => ({
RefField: vi.fn(() => <div data-testid="ref-field">Ref Field</div>),
}));
vi.mock('@/react/portainer/gitops/AuthFieldset', async (importOriginal) => ({
...(await importOriginal()),
AuthFieldset: vi.fn(() => (
<div data-testid="auth-fieldset">
<div>Repository Authentication</div>
</div>
)),
}));
vi.mock(
'@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset',
() => ({
RelativePathFieldset: vi.fn(() => (
<div data-testid="relative-path-fieldset">Relative Path Fieldset</div>
)),
})
);
vi.mock('@@/form-components/MultiRegistrySelectFieldset', () => ({
MultiRegistrySelectFieldset: vi.fn(
({
options,
}: {
options: Array<{ Id: number; Name: string }>;
value: number[];
}) => (
<div data-testid="multi-registry-select">
{options?.map((registry: { Id: number; Name: string }) => (
<span key={registry.Id}>{registry.Name}</span>
))}
</div>
)
),
}));
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
notifyError: vi.fn(),
}));
vi.mock('@/react/docker/proxy/queries/useVersion', () => ({
useApiVersion: vi.fn(),
}));
// In test setup or beforeEach
beforeEach(() => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
});
const mockConfirmStackUpdate = vi.mocked(confirmStackUpdate);
const mockConfirmEnableTLSVerify = vi.mocked(confirmEnableTLSVerify);
const mockBaseStackWebhookUrl = vi.mocked(baseStackWebhookUrl);
const mockCreateWebhookId = vi.mocked(createWebhookId);
describe('StackRedeployGitForm', () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfirmStackUpdate.mockResolvedValue({ pullImage: false });
mockConfirmEnableTLSVerify.mockResolvedValue(true);
mockBaseStackWebhookUrl.mockReturnValue(
'http://localhost:9000/api/webhooks'
);
mockCreateWebhookId.mockReturnValue('test-webhook-id');
server.use(
http.put('/api/stacks/:id/git/redeploy', () =>
HttpResponse.json({ success: true })
),
http.post('/api/stacks/:id/git', () =>
HttpResponse.json({ success: true })
)
);
});
describe('Basic rendering', () => {
it('should render the form with correct sections', () => {
renderComponent();
expect(
screen.getByText('Redeploy from git repository')
).toBeInTheDocument();
expect(screen.getByText('Options')).toBeInTheDocument(); // available only when apiVersion is >= 1.27
expect(screen.getByText('Actions')).toBeInTheDocument();
});
it('should display repository information in InfoPanel', () => {
renderComponent();
expect(
screen.getByText('https://github.com/test/repo')
).toBeInTheDocument();
expect(screen.getByText('docker-compose.yml')).toBeInTheDocument();
});
it('should show advanced configuration toggle button', () => {
renderComponent();
expect(screen.getByText('Advanced configuration')).toBeInTheDocument();
expect(
screen.getByTestId('advanced-configuration-toggle-button')
).toBeInTheDocument();
});
it('should show Pull and redeploy button', () => {
renderComponent();
expect(screen.getByText('Pull and redeploy')).toBeInTheDocument();
expect(screen.getByTestId('stack-redeploy-button')).toBeInTheDocument();
});
it('should show Save settings button', () => {
renderComponent();
expect(screen.getByText('Save settings')).toBeInTheDocument();
expect(
screen.getByTestId('stack-save-settings-button')
).toBeInTheDocument();
});
});
describe('Advanced configuration toggle', () => {
it('should show advanced configuration when toggle is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
expect(screen.getByText('Hide configuration')).toBeInTheDocument();
expect(screen.getByText('Skip TLS Verification')).toBeInTheDocument();
});
it('should hide advanced configuration when toggle is clicked again', async () => {
const user = userEvent.setup();
renderComponent();
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
await user.click(toggleButton);
expect(screen.getByText('Advanced configuration')).toBeInTheDocument();
expect(
screen.queryByText('Skip TLS Verification')
).not.toBeInTheDocument();
});
});
describe('TLS Skip Verification', () => {
it('should show TLS skip verification switch in advanced config', async () => {
const user = userEvent.setup();
renderComponent();
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
expect(
screen.getByTestId('gitops-skip-tls-verification-switch')
).toBeInTheDocument();
});
it('should call confirmEnableTLSVerify when enabling TLS verification', async () => {
const user = userEvent.setup();
const propsWithTLSDisabled: DeepPartial<StackRedeployGitFormProps> = {
stack: {
GitConfig: {
TLSSkipVerify: true,
},
},
};
renderComponent(propsWithTLSDisabled);
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
expect(mockConfirmEnableTLSVerify).toHaveBeenCalled();
});
});
describe('Options section', () => {
it('should show prune services option for swarm stacks with API version >= 1.27', () => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
renderComponent();
expect(screen.getByText('Prune services')).toBeInTheDocument();
expect(
screen.getByTestId('stack-prune-services-switch')
).toBeInTheDocument();
});
it('should not show options section for non-swarm stacks', () => {
vi.mocked(useApiVersion).mockReturnValue(1.27);
renderComponent({
stack: {
Type: 2,
},
});
expect(screen.queryByText('Options')).not.toBeInTheDocument();
});
it('should not show options section for older API versions', () => {
vi.mocked(useApiVersion).mockReturnValue(1.26);
renderComponent();
expect(screen.queryByText('Options')).not.toBeInTheDocument();
});
});
describe('Pull and redeploy functionality', () => {
it('should call confirmStackUpdate when redeploy button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
expect(mockConfirmStackUpdate).toHaveBeenCalledWith(
'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?',
true
);
});
it('should call updateGitStack mutation when confirmed', async () => {
let requestBody: unknown = null;
server.use(
http.put('/api/stacks/:id/git/redeploy', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
prune: false,
RepositoryReferenceName: 'refs/heads/main',
})
);
});
});
it('should notify success on successful redeploy', async () => {
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ success: true })
)
);
const user = userEvent.setup();
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
await waitFor(() => {
expect(notifySuccess).toHaveBeenCalled();
});
});
it('should disable redeploy button when in progress', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () => {
// never resolve
await new Promise(() => {});
return HttpResponse.json({ success: true });
})
);
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
// The button should be disabled during the redeploy process
await waitFor(() => {
expect(redeployButton).toBeDisabled();
});
});
});
describe('Save settings functionality', () => {
it('should call updateGitStackSettings mutation when save button is clicked', async () => {
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
renderComponent();
// Make a change to enable the save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
RepositoryReferenceName: 'refs/heads/main',
prune: false,
TLSSkipVerify: true,
})
);
});
});
it('should disable save button when no changes are made', () => {
renderComponent();
const saveButton = screen.getByTestId('stack-save-settings-button');
expect(saveButton).toBeDisabled();
});
it('should enable save button when changes are made', async () => {
const user = userEvent.setup();
renderComponent();
// Make a change to enable the save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
expect(saveButton).not.toBeDisabled();
});
it('should disable save button when in progress', () => {
server.use(
http.post('/api/stacks/:id/git', async () => {
// never resolve
await new Promise(() => {});
return HttpResponse.json({ success: true });
})
);
renderComponent();
const saveButton = screen.getByTestId('stack-save-settings-button');
expect(saveButton).toBeDisabled();
});
});
describe('Form state management', () => {
it('should track unsaved changes correctly', async () => {
const user = userEvent.setup();
renderComponent();
// Initially no unsaved changes
const saveButton = screen.getByTestId('stack-save-settings-button');
expect(saveButton).toBeDisabled();
// Make a change
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
// Should now have unsaved changes
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
it('should clear unsaved changes after successful save', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ success: true })
)
);
renderComponent();
// Make a change
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
// Save the changes
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(saveButton).toBeDisabled();
});
});
});
describe('Error handling', () => {
it('should handle updateGitStack mutation errors gracefully', async () => {
const user = userEvent.setup();
server.use(
http.put('/api/stacks/:id/git/redeploy', async () =>
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
)
);
renderComponent();
const redeployButton = screen.getByTestId('stack-redeploy-button');
await user.click(redeployButton);
await waitFor(() => {
expect(notifyError).toHaveBeenCalled();
});
});
it('should handle updateGitStackSettings mutation errors gracefully', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/stacks/:id/git', async () =>
HttpResponse.json({ error: 'Update failed' }, { status: 400 })
)
);
renderComponent();
// Make a change to enable save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
// Should not clear unsaved changes on error
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
});
});
describe('Git authentication', () => {
it('should handle git authentication configuration', async () => {
const user = userEvent.setup();
const propsWithAuth: DeepPartial<StackRedeployGitFormProps> = {
stack: {
GitConfig: {
Authentication: {
Username: 'testuser',
Password: 'testpass',
GitCredentialID: 0,
},
},
},
};
renderComponent(propsWithAuth);
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
// Should show authentication fields
expect(screen.getByText('Repository Authentication')).toBeInTheDocument();
});
});
describe('Webhook configuration', () => {
it('should generate webhook ID when no webhook is provided and use it in save settings', async () => {
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
const user = userEvent.setup();
mockCreateWebhookId.mockReturnValue('generated-webhook-id');
renderComponent({
stack: {
AutoUpdate: {
Webhook: '',
},
},
});
expect(mockCreateWebhookId).toHaveBeenCalled();
// Make a change to enable save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
AutoUpdate: expect.objectContaining({
Webhook: 'generated-webhook-id',
}),
})
);
});
});
it('should use existing webhook ID from stack without generating new one', async () => {
const user = userEvent.setup();
let requestBody: unknown;
server.use(
http.post('/api/stacks/:id/git', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({ success: true });
})
);
renderComponent({
stack: {
AutoUpdate: {
Webhook: 'existing-webhook-id',
},
},
});
expect(mockCreateWebhookId).not.toHaveBeenCalled();
// Make a change to enable save button
const toggleButton = screen.getByTestId(
'advanced-configuration-toggle-button'
);
await user.click(toggleButton);
const tlsSwitch = screen.getByTestId(
'gitops-skip-tls-verification-switch'
);
await user.click(tlsSwitch);
const saveButton = screen.getByTestId('stack-save-settings-button');
await user.click(saveButton);
await waitFor(() => {
expect(requestBody).toEqual(
expect.objectContaining({
AutoUpdate: expect.objectContaining({
Webhook: 'existing-webhook-id',
}),
})
);
});
});
});
});
const defaultProps: StackRedeployGitFormProps = {
stack: {
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'refs/heads/main',
ConfigFilePath: 'docker-compose.yml',
ConfigHash: 'abc123',
TLSSkipVerify: false,
},
Name: 'stack',
Id: 1,
EndpointId: 1,
Type: 1, // Swarm stack
Env: [
{ name: 'ENV1', value: 'value1' },
{ name: 'ENV2', value: 'value2' },
],
Option: {
Prune: false,
Force: false,
},
AdditionalFiles: ['file1.yml', 'file2.yml'],
AutoUpdate: {
Interval: '5m',
Webhook: 'test-webhook-id',
ForceUpdate: false,
ForcePullImage: false,
},
} as Stack,
};
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
function renderComponent(props: DeepPartial<StackRedeployGitFormProps> = {}) {
const Component = withTestQueryProvider(
withUserProvider(withTestRouter(StackRedeployGitForm))
);
// merge deep the props
return render(<Component {..._.merge({}, defaultProps, props)} />);
}
@@ -0,0 +1,144 @@
import { Formik, FormikHelpers } from 'formik';
import { useState } from 'react';
import { useRouter } from '@uirouter/react';
import { GitStackPayload, Stack, StackType } from '@/react/common/stacks/types';
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import {
parseAutoUpdateResponse,
transformAutoUpdateViewModel,
} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { useUpdateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { useUpdateGitStackSettings } from '@/react/portainer/gitops/queries/useUpdateGitStackSettings';
import { useValidationSchema } from './useValidationSchema';
import { FormValues } from './types';
import { InnerForm } from './InnerForm';
export function StackRedeployGitForm({ stack }: { stack: Stack }) {
const router = useRouter();
const deployMutation = useUpdateGitStack(stack.Id, stack.EndpointId);
const updateSettingsMutation = useUpdateGitStackSettings();
const validationSchema = useValidationSchema({
isAuthEdit: !!stack.GitConfig?.Authentication,
});
const [webhookId] = useState(() => {
if (!stack.AutoUpdate?.Webhook) {
return createWebhookId();
}
return stack.AutoUpdate?.Webhook;
});
const authValues = stack.GitConfig?.Authentication;
const initialValues: FormValues = {
auth: {
NewCredentialName: '',
RepositoryAuthentication: !!authValues,
RepositoryAuthorizationType: authValues?.AuthorizationType,
RepositoryGitCredentialID: authValues?.GitCredentialID,
RepositoryPassword: authValues?.Password,
RepositoryUsername: authValues?.Username,
SaveCredential: false,
},
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
env: stack.Env,
prune: stack.Option?.Prune || false,
refName: stack.GitConfig?.ReferenceName || '',
tlsSkipVerify: stack.GitConfig?.TLSSkipVerify || false,
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSaveSettings}
>
<InnerForm
stack={stack}
webhookId={webhookId}
isSaveLoading={updateSettingsMutation.isLoading}
isDeployLoading={deployMutation.isLoading}
onDeploy={handleDeploy}
/>
</Formik>
);
function handleSaveSettings(
values: FormValues,
{ resetForm }: FormikHelpers<FormValues>
) {
const autoUpdate = transformAutoUpdateViewModel(
values.autoUpdate,
webhookId
);
const payload: GitStackPayload = {
AutoUpdate: autoUpdate,
env: values.env,
RepositoryReferenceName: values.refName,
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
prune: values.prune,
TLSSkipVerify: values.tlsSkipVerify,
};
updateSettingsMutation.mutate(
{
stackId: stack.Id,
endpointId: stack.EndpointId,
payload,
},
{
onError(err) {
notifyError('Failure', err as Error, 'Unable to save stack settings');
},
onSuccess() {
notifySuccess('Success', 'Save stack settings successfully');
resetForm({ values });
},
}
);
}
async function handleDeploy(values: FormValues) {
const isSwarmStack = stack.Type === StackType.DockerSwarm;
const result = await confirmStackUpdate(
'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?',
isSwarmStack
);
if (!result) {
return;
}
const payload: GitStackPayload = {
PullImage: result.pullImage,
env: values.env,
RepositoryReferenceName: values.refName,
RepositoryAuthentication: values.auth.RepositoryAuthentication,
RepositoryGitCredentialID: values.auth.RepositoryGitCredentialID,
RepositoryUsername: values.auth.RepositoryUsername,
RepositoryPassword: values.auth.RepositoryPassword,
RepositoryAuthorizationType: values.auth.RepositoryAuthorizationType,
prune: values.prune,
TLSSkipVerify: values.tlsSkipVerify,
};
deployMutation.mutate(payload, {
onError(err) {
notifyError('Failure', err as Error, 'Failed redeploying stack');
},
onSuccess() {
notifySuccess('Success', 'Pulled and redeployed stack successfully');
router.stateService.reload();
},
});
}
}
@@ -0,0 +1,36 @@
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
import { SwitchField } from '@@/form-components/SwitchField';
interface Props {
value: boolean;
initialValue: boolean;
onChange: (value: boolean) => void;
}
export function TLSVerificationField({ value, initialValue, onChange }: Props) {
return (
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="TLSSkipVerify"
checked={value}
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
labelClass="col-sm-3 col-lg-2"
label="Skip TLS Verification"
onChange={async (newValue) => {
if (initialValue && !newValue) {
const confirmed = await confirmEnableTLSVerify();
if (!confirmed) {
return;
}
}
onChange(newValue);
}}
data-cy="gitops-skip-tls-verification-switch"
/>
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { GitAuthModel, AutoUpdateModel } from '@/react/portainer/gitops/types';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
export interface FormValues {
refName: string;
env: EnvVarValues;
prune: boolean;
tlsSkipVerify: boolean;
auth: GitAuthModel;
autoUpdate: AutoUpdateModel;
}
@@ -0,0 +1,29 @@
import { array, boolean, number, object, SchemaOf, string } from 'yup';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormValues } from './types';
export function useValidationSchema({
isAuthEdit,
}: {
isAuthEdit: boolean;
}): SchemaOf<FormValues> {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
return object({
auth: gitAuthValidation(gitCredentialsQuery.data || [], isAuthEdit, false),
refName: string().default(''),
env: envVarValidation(),
prune: boolean().default(false),
registries: array(number().required()),
tlsSkipVerify: boolean().default(false),
autoUpdate: autoUpdateValidation(),
});
}