refactor(environments): migrate general environment form to react (#1706)

This commit is contained in:
Chaim Lev-Ari
2026-01-26 23:10:01 +05:30
committed by GitHub
parent 1b70fe5770
commit 2c8126e244
10 changed files with 508 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AzureEnvironmentForm } from '@/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm';
import { GeneralEnvironmentForm } from '@/react/portainer/environments/ItemView/GeneralEnvironmentForm/GeneralEnvironmentForm';
export const environmentsModule = angular
.module('portainer.app.react.components.environments', [])
@@ -63,4 +64,11 @@ export const environmentsModule = angular
'environment',
'onSuccess',
])
)
.component(
'generalEnvironmentForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(GeneralEnvironmentForm))), [
'environment',
'onSuccess',
])
).name;

View File

@@ -26,7 +26,13 @@
<azure-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></azure-environment-form>
</div>
<div class="row mt-4" ng-if="!state.azureEndpoint">
<div class="row mt-4" ng-if="!state.azureEndpoint && !state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12" ng-if="endpoint">
<general-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></general-environment-form>
</div>
</div>
<div class="row mt-4" ng-if="state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>

View File

@@ -0,0 +1,220 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DefaultBodyType, http, HttpResponse } from 'msw';
import { describe, it, expect, vi } from 'vitest';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import {
Environment,
EnvironmentType,
EnvironmentStatus,
} from '@/react/portainer/environments/types';
import {
createMockEnvironment,
createMockUser,
} from '@/react-tools/test-mocks';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { GeneralEnvironmentForm } from './GeneralEnvironmentForm';
describe('GeneralEnvironmentForm', () => {
it('should render all fields for Docker environment', async () => {
const dockerEnv = createMockEnvironment({
Type: EnvironmentType.Docker,
URL: 'unix:///var/run/docker.sock',
});
renderComponent(dockerEnv);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
expect(screen.getByLabelText('Environment URL')).toBeVisible();
expect(screen.getByLabelText('Public IP')).toBeVisible();
expect(screen.queryByText(/TLS/i)).not.toBeInTheDocument();
});
it('should render TLS section for Docker API environment', async () => {
const dockerApiEnv = createMockEnvironment({
Type: EnvironmentType.Docker,
URL: 'tcp://10.0.0.1:2375',
});
renderComponent(dockerApiEnv);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
expect(screen.getByLabelText('Environment URL')).toBeVisible();
expect(screen.getByLabelText('Public IP')).toBeVisible();
expect(screen.getByText(/TLS/i)).toBeVisible();
});
it('should not show TLS section for Kubernetes environment', async () => {
const k8sEnv = createMockEnvironment({
Type: EnvironmentType.AgentOnKubernetes,
});
renderComponent(k8sEnv);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
expect(screen.queryByText(/TLS/i)).not.toBeInTheDocument();
});
it('should not show URL fields when environment has error', async () => {
const errorEnv = createMockEnvironment({
Status: EnvironmentStatus.Error,
});
renderComponent(errorEnv);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
expect(screen.queryByLabelText('Environment URL')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Public IP')).not.toBeInTheDocument();
});
it('should validate required fields', async () => {
const env = createMockEnvironment();
renderComponent(env);
const nameInput = screen.getByRole('textbox', { name: /Name/ });
await waitFor(() => {
expect(nameInput).toBeVisible();
});
await userEvent.clear(nameInput);
await userEvent.tab();
await waitFor(() => {
expect(screen.getByText(/Name is required/i)).toBeVisible();
});
});
it('should strip protocol from URL on load', async () => {
const env = createMockEnvironment({ URL: 'tcp://10.0.0.1:2375' });
renderComponent(env);
await waitFor(() => {
const urlInput = screen.getByLabelText('Environment URL');
expect(urlInput).toHaveValue('10.0.0.1:2375');
});
});
it('should handle submission', async () => {
let requestPayload: DefaultBodyType;
server.use(
http.put('/api/endpoints/:id', async ({ request }) => {
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
requestPayload = await request.json();
return HttpResponse.json({});
})
);
const env = createMockEnvironment({ Id: 1, Name: 'test-env' });
const onSuccess = vi.fn();
renderComponent(env, { onSuccess });
const nameInput = screen.getByRole('textbox', { name: /Name/ });
await waitFor(() => {
expect(nameInput).toBeVisible();
});
// Fill form fields
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'my-environment');
expect(nameInput).toHaveValue('my-environment');
const urlInput = screen.getByLabelText('Environment URL');
await userEvent.clear(urlInput);
await userEvent.type(urlInput, '10.0.0.1:2375');
const publicUrlInput = screen.getByLabelText('Public IP');
await userEvent.type(publicUrlInput, '1.2.3.4');
// Wait for debounce to complete (NameField uses useDebounce)
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
const submitButton = screen.getByRole('button', {
name: /update environment/i,
});
// Wait for Formik to process all changes and enable submit button
await waitFor(() => {
expect(submitButton).toBeEnabled();
});
await userEvent.click(submitButton);
expect(await screen.findByText(/updating environment/i)).toBeVisible();
// Verify payload
await waitFor(() => {
expect(requestPayload).toMatchObject({
Name: 'my-environment',
URL: 'tcp://10.0.0.1:2375',
PublicURL: '1.2.3.4',
});
});
expect(onSuccess).toHaveBeenCalled();
});
it('should render cancel button', async () => {
const env = createMockEnvironment();
renderComponent(env);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
expect(screen.getByText('Cancel')).toBeVisible();
});
it('should disable submit button when form is invalid', async () => {
const env = createMockEnvironment();
renderComponent(env);
await waitFor(() => {
expect(screen.getByRole('textbox', { name: /Name/ })).toBeVisible();
});
// Clear required field to make form invalid
const nameInput = screen.getByRole('textbox', { name: /Name/ });
await userEvent.clear(nameInput);
await waitFor(() => {
const submitButton = screen.getByRole('button', {
name: /update environment/i,
});
expect(submitButton).toBeDisabled();
expect(screen.getAllByRole('alert').length).toBeGreaterThan(0);
});
});
});
function renderComponent(
environment: Environment,
{ onSuccess = vi.fn() } = {}
) {
const Wrapped = withUserProvider(
withTestRouter(withTestQueryProvider(GeneralEnvironmentForm)),
createMockUser({
Id: 1,
Username: 'admin',
Role: 1,
})
);
return render(<Wrapped environment={environment} onSuccess={onSuccess} />);
}

View File

@@ -0,0 +1,99 @@
import { Formik, Form } from 'formik';
import { useUpdateEnvironmentMutation } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import { NameField } from '@/react/portainer/environments/common/NameField/NameField';
import { EnvironmentUrlField } from '@/react/portainer/environments/common/EnvironmentUrlField/EnvironmentUrlField';
import { PublicUrlField } from '@/react/portainer/environments/common/PublicUrlField/PublicUrlField';
import { TLSFieldset } from '@/react/components/TLSFieldset';
import { MetadataFieldset } from '@/react/portainer/environments/common/MetadataFieldset';
import {
isAgentEnvironment,
isDockerAPIEnvironment,
isLocalEnvironment,
} from '@/react/portainer/environments/utils';
import {
Environment,
EnvironmentStatus,
} from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { Widget } from '@@/Widget/Widget';
import { WidgetBody } from '@@/Widget';
import { EnvironmentFormActions } from '../EnvironmentFormActions/EnvironmentFormActions';
import { useGeneralValidation } from './validation';
import { buildInitialValues, buildUpdatePayload } from './helpers';
interface Props {
environment: Environment;
onSuccess: () => void;
}
export function GeneralEnvironmentForm({ environment, onSuccess }: Props) {
const updateMutation = useUpdateEnvironmentMutation();
const isDockerAPI = isDockerAPIEnvironment(environment);
const isAgent = isAgentEnvironment(environment.Type);
const isLocal = isLocalEnvironment(environment);
const hasError = environment.Status === EnvironmentStatus.Error;
const validationSchema = useGeneralValidation({
status: environment.Status,
environmentId: environment.Id,
});
return (
<Widget>
<WidgetBody>
<Formik
initialValues={buildInitialValues(environment)}
validationSchema={validationSchema}
onSubmit={(values) => {
const payload = buildUpdatePayload(values, environment.Type);
updateMutation.mutate(
{ id: environment.Id, payload },
{ onSuccess }
);
}}
validateOnMount
>
{(formik) => (
<Form className="form-horizontal">
<FormSection title="Configuration">
<NameField />
{!hasError && (
<>
<EnvironmentUrlField isAgent={isAgent} disabled={isLocal} />
<PublicUrlField />
</>
)}
{!hasError && isDockerAPI && (
<TLSFieldset
values={formik.values.tls}
onChange={(partialValues) => {
formik.setFieldValue('tls', {
...formik.values.tls,
...partialValues,
});
}}
errors={formik.errors.tls}
/>
)}
</FormSection>
<MetadataFieldset />
<EnvironmentFormActions
isLoading={updateMutation.isLoading}
isValid={formik.isValid}
isDirty={formik.dirty}
/>
</Form>
)}
</Formik>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { formatURL } from './helpers';
describe('helpers', () => {
describe('formatURL', () => {
it('should add tcp:// prefix for Docker environments', () => {
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should add tcp:// prefix for Docker standalone', () => {
const result = formatURL('10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should add tcp:// prefix for Agent environment', () => {
const result = formatURL('agent-host', EnvironmentType.AgentOnDocker);
expect(result).toBe('tcp://agent-host');
});
it('should add https:// prefix for Kubernetes Local', () => {
const result = formatURL(
'k8s.example.com',
EnvironmentType.KubernetesLocal
);
expect(result).toBe('https://k8s.example.com');
});
it('should not add prefix for Agent on Kubernetes', () => {
const result = formatURL('k8s-agent', EnvironmentType.AgentOnKubernetes);
expect(result).toBe('k8s-agent');
});
it('should strip existing protocol before formatting', () => {
const result = formatURL('tcp://10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
it('should handle empty URL', () => {
const result = formatURL('', EnvironmentType.Docker);
expect(result).toBe('');
});
it('should strip https:// and add tcp:// for Docker', () => {
const result = formatURL('https://10.0.0.1:2375', EnvironmentType.Docker);
expect(result).toBe('tcp://10.0.0.1:2375');
});
});
});

View File

@@ -0,0 +1,73 @@
import {
Environment,
EnvironmentType,
} from '@/react/portainer/environments/types';
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
import { stripProtocol } from '@/react/common/string-utils';
import { GeneralEnvironmentFormValues } from './types';
export function buildInitialValues(
environment: Environment
): GeneralEnvironmentFormValues {
return {
name: environment.Name,
environmentUrl: stripProtocol(environment.URL),
publicUrl: environment.PublicURL || '',
meta: {
groupId: environment.GroupId,
tagIds: environment.TagIds || [],
},
tls: {
tls: environment.TLSConfig?.TLS || false,
skipVerify: environment.TLSConfig?.TLSSkipVerify || false,
caCertFile: undefined,
certFile: undefined,
keyFile: undefined,
},
};
}
export function buildUpdatePayload(
values: GeneralEnvironmentFormValues,
environmentType: EnvironmentType
): Partial<UpdateEnvironmentPayload> {
return {
Name: values.name,
PublicURL: values.publicUrl,
GroupID: values.meta.groupId,
TagIds: values.meta.tagIds,
URL: formatURL(values.environmentUrl, environmentType),
TLS: values.tls.tls,
TLSSkipVerify: values.tls.skipVerify,
TLSSkipClientVerify: values.tls.skipVerify,
TLSCACert: values.tls.caCertFile,
TLSCert: values.tls.certFile,
TLSKey: values.tls.keyFile,
};
}
// URL Formatting Logic (from Angular controller lines 195-242)
export function formatURL(url: string, type: EnvironmentType): string {
if (!url) return '';
// Strip any existing protocol
const stripped = stripProtocol(url);
// Kubernetes Local - prefix https://
if (type === EnvironmentType.KubernetesLocal) {
return `https://${stripped}`;
}
// Agent on Kubernetes - use as-is
if (type === EnvironmentType.AgentOnKubernetes) {
return stripped;
}
// Default (Docker) - prefix tcp://
return `tcp://${stripped}`;
}

View File

@@ -0,0 +1,12 @@
import { TLSConfig } from '@/react/components/TLSFieldset/types';
import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create';
export interface GeneralEnvironmentFormValues {
name: string;
environmentUrl: string;
publicUrl: string;
tls: TLSConfig;
meta: EnvironmentMetadata;
}

View File

@@ -0,0 +1,33 @@
import { object, string, SchemaOf } from 'yup';
import { tlsConfigValidation } from '@/react/components/TLSFieldset/TLSFieldset';
import {
EnvironmentId,
EnvironmentStatus,
} from '@/react/portainer/environments/types';
import { useNameValidation } from '../../common/NameField/NameField';
import { metadataValidation } from '../../common/MetadataFieldset/validation';
import { GeneralEnvironmentFormValues } from './types';
export function useGeneralValidation({
status,
environmentId,
}: {
status: EnvironmentStatus;
environmentId: EnvironmentId;
}): SchemaOf<GeneralEnvironmentFormValues> {
const nameValidation = useNameValidation(environmentId);
return object({
name: nameValidation,
environmentUrl:
status !== EnvironmentStatus.Error
? string().required('Environment address is required')
: string().default(''),
publicUrl: string().default(''),
tls: tlsConfigValidation({ optionalCert: true }),
meta: metadataValidation(),
});
}

View File

@@ -1,6 +1,8 @@
import { TagId } from '@/portainer/tags/types';
import { DockerSnapshot } from '@/react/docker/snapshots/types';
import { TLSConfiguration } from '../settings/types';
export type EnvironmentGroupId = number;
export type EdgeGroupId = number;
@@ -166,10 +168,7 @@ export type Environment = {
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus?: { name: string; value: string }[];
TLSConfig?: {
TLS: boolean;
TLSSkipVerify: boolean;
};
TLSConfig?: TLSConfiguration;
AzureCredentials?: {
ApplicationID: string;
TenantID: string;

View File

@@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw';
import { createMockEnvironment } from '@/react-tools/test-mocks';
export const endpointsHandlers = [
http.get('/api/endpoints', () => HttpResponse.json([])),
http.get('/api/endpoints/agent_versions', () => HttpResponse.json([])),
http.get('/api/endpoints/:endpointId', ({ params }) =>
HttpResponse.json(