refactor(environments): migrate general environment form to react (#1706)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user