fix(edge-stacks): various custom template issues [BE-11414] (#189)

release/2.25
Ali 2024-12-09 17:48:34 +13:00 committed by GitHub
parent 16a1825990
commit 97e7a3c5e2
24 changed files with 749 additions and 374 deletions

View File

@ -74,6 +74,10 @@ angular
data: {
docs: '/user/edge/stacks/add',
},
params: {
templateId: { dynamic: true },
templateType: { dynamic: true },
},
};
const stacksEdit = {

View File

@ -271,7 +271,7 @@
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
ng-click="$ctrl.updateRegistry()"
button-spinner="$ctrl.state.actionInProgress"

View File

@ -14,7 +14,7 @@ export function RadioGroup<T extends string | number = string>({
onOptionChange,
}: Props<T>) {
return (
<div>
<div className="flex flex-wrap gap-x-2 gap-y-1">
{options.map((option) => (
<label
key={option.value}

View File

@ -1,186 +0,0 @@
import { DefaultBodyType, HttpResponse } from 'msw';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { CreateForm } from './CreateForm';
// browser address
// /edge/stacks/new?templateId=54&templateType=app
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { templateId: 54, templateType: 'app' },
})),
}));
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div />,
}));
// app templates request
// GET /api/templates
const templatesResponseBody = {
version: '3',
templates: [
{
id: 54,
type: 3,
title: 'TOSIBOX Lock for Container',
description:
'Lock for Container brings secure connectivity inside your industrial IoT devices',
administrator_only: false,
image: '',
repository: {
url: 'https://github.com/portainer/templates',
stackfile: 'stacks/tosibox/docker-compose.yml',
},
stackFile: '',
logo: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/tosibox.png',
env: [
{
name: 'LICENSE_KEY',
label: 'License key',
},
],
platform: 'linux',
categories: ['edge'],
},
],
};
// app template content request
// GET /api/templates/54/file
const templateContentResponseBody = {
FileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
// edge groups
const edgeGroups = [
{
Id: 1,
Name: 'docker',
Dynamic: false,
TagIds: [],
Endpoints: [12],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [4],
TrustedEndpoints: [12],
},
{
Id: 2,
Name: 'kubernetes',
Dynamic: false,
TagIds: [],
Endpoints: [11],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [7],
TrustedEndpoints: [11],
},
];
// expected form values
const expectedPayload = {
deploymentType: 0,
edgeGroups: [1],
name: 'my-stack',
envVars: [{ name: 'LICENSE_KEY', value: 'license-123' }],
prePullImage: false,
registries: [],
retryDeploy: false,
staggerConfig: {
StaggerOption: 1,
StaggerParallelOption: 1,
DeviceNumber: 1,
DeviceNumberStartFrom: 0,
DeviceNumberIncrementBy: 2,
Timeout: '',
UpdateDelay: '',
UpdateFailureAction: 1,
},
useManifestNamespaces: false,
stackFileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
function renderCreateForm() {
server.use(
http.get('/api/templates', () => HttpResponse.json(templatesResponseBody))
);
server.use(
http.post('/api/templates/54/file', () =>
HttpResponse.json(templateContentResponseBody)
)
);
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
server.use(http.get('/api/registries', () => HttpResponse.json([])));
server.use(http.get('/api/custom_templates', () => HttpResponse.json([])));
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateForm), user)
);
return render(<Wrapped />);
}
test('The web editor should be visible for app templates', async () => {
const { getByRole, getByLabelText } = renderCreateForm();
// Wait for the form to be rendered
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// the web editor should be visible
expect(getByLabelText('Web editor')).toBeVisible();
});
test('The form should submit the correct request body', async () => {
let requestBody: DefaultBodyType;
server.use(
http.post('/api/edge_stacks/create/string', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({});
})
);
const { getByRole, getByLabelText } = renderCreateForm();
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// fill in the name and select the docker edge group
const user = userEvent.setup();
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
await user.type(
getByRole('textbox', { name: 'License key *' }),
'license-123'
);
const selectElement = getByLabelText('Edge groups');
await selectEvent.select(selectElement, 'docker');
// submit the form
await user.click(getByRole('button', { name: /Deploy the stack/i }));
// verify the request body
await waitFor(() => {
expect(requestBody).toEqual(expectedPayload);
});
});

View File

@ -1,5 +1,5 @@
import { Formik } from 'formik';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
@ -18,12 +18,12 @@ import { DeploymentType } from '../types';
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { InnerForm } from './InnerForm';
import { FormValues } from './types';
import { useValidation } from './CreateForm.validation';
import { Values as TemplateValues } from './TemplateFieldset/types';
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
import { useTemplateParams } from './useTemplateParams';
import { useCreate } from './useCreate';
import { FormValues } from './types';
export function CreateForm() {
const [webhookId] = useState(() => createWebhookId());
@ -38,33 +38,12 @@ export function CreateForm() {
templateType: templateParams.type,
});
if (
templateParams.id &&
!(templateQuery.customTemplate || templateQuery.appTemplate)
) {
const initialValues = useInitialValues(templateQuery, templateParams);
if (!initialValues) {
return null;
}
const template = templateQuery.customTemplate || templateQuery.appTemplate;
const initialValues: FormValues = {
name: '',
groupIds: [],
deploymentType: DeploymentType.Compose,
envVars: [],
privateRegistryId: 0,
prePullImage: false,
retryDeploy: false,
staggerConfig: getDefaultStaggerConfig(),
method: templateParams.id ? 'template' : 'editor',
git: toGitFormModel(undefined, parseAutoUpdateResponse()),
relativePath: getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
useManifestNamespaces: false,
};
return (
<div className="row">
<div className="col-sm-12">
@ -128,7 +107,66 @@ function useTemplate(
});
return {
appTemplate: appTemplateQuery.data,
customTemplate: customTemplateQuery.data,
appTemplate: type === 'app' ? appTemplateQuery.data : undefined,
customTemplate: type === 'custom' ? customTemplateQuery.data : undefined,
};
}
function useInitialValues(
templateQuery: {
appTemplate: TemplateViewModel | undefined;
customTemplate: CustomTemplate | undefined;
},
templateParams: {
id: number | undefined;
type: 'app' | 'custom' | undefined;
}
) {
const template = templateQuery.customTemplate || templateQuery.appTemplate;
const initialValues: FormValues = useMemo(
() => ({
name: '',
groupIds: [],
// if edge custom templates allow kube manifests/helm charts in future, add logic for setting this for the initial deploymentType
deploymentType: DeploymentType.Compose,
envVars: [],
privateRegistryId:
templateQuery.customTemplate?.EdgeSettings?.PrivateRegistryId ?? 0,
prePullImage:
templateQuery.customTemplate?.EdgeSettings?.PrePullImage ?? false,
retryDeploy:
templateQuery.customTemplate?.EdgeSettings?.RetryDeploy ?? false,
staggerConfig:
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
getDefaultStaggerConfig(),
method: templateParams.id ? 'template' : 'editor',
git: toGitFormModel(
templateQuery.customTemplate?.GitConfig,
parseAutoUpdateResponse()
),
relativePath:
templateQuery.customTemplate?.EdgeSettings?.RelativePathSettings ??
getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
useManifestNamespaces: false,
}),
[
templateQuery.customTemplate,
templateParams.id,
templateParams.type,
template,
]
);
if (
templateParams.id &&
!templateQuery.customTemplate &&
!templateQuery.appTemplate
) {
return null;
}
return initialValues;
}

View File

@ -117,7 +117,7 @@ export function useValidation({
}),
templateValues: templateFieldsetValidation({
customVariablesDefinitions: customTemplate?.Variables || [],
envVarDefinitions: appTemplate?.Env || [],
appTemplateVariablesDefinitions: appTemplate?.Env || [],
}),
git: mixed().when('method', {
is: 'repository',

View File

@ -1,4 +1,5 @@
import { useFormikContext } from 'formik';
import { FormikErrors, useFormikContext } from 'formik';
import { SetStateAction } from 'react';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
@ -24,31 +25,18 @@ import { useRenderAppTemplate } from './useRenderAppTemplate';
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
export function DockerComposeForm({
webhookId,
onChangeTemplate,
}: {
interface Props {
webhookId: string;
onChangeTemplate: ({
type,
id,
}: {
onChangeTemplate: (change: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
}) => void;
}) {
}
export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
const { method } = values;
const { customTemplate, isInitialLoading: isCustomTemplateLoading } =
useRenderCustomTemplate(values.templateValues, setValues);
const { appTemplate, isInitialLoading: isAppTemplateLoading } =
useRenderAppTemplate(values.templateValues, setValues);
const isTemplate =
method === edgeStackTemplate.value && (customTemplate || appTemplate);
const isTemplateLoading = isCustomTemplateLoading || isAppTemplateLoading;
return (
<>
<FormSection title="Build Method">
@ -62,10 +50,10 @@ export function DockerComposeForm({
</FormSection>
{method === edgeStackTemplate.value && (
<TemplateFieldset
values={values.templateValues}
setValues={(templateAction) =>
setValues((values) => {
<>
<TemplateFieldset
values={values.templateValues}
setValues={(templateAction) => {
const templateValues = applySetStateAction(
templateAction,
values.templateValues
@ -74,25 +62,36 @@ export function DockerComposeForm({
id: templateValues.templateId,
type: templateValues.type,
});
return {
setValues((values) => ({
...values,
templateValues,
};
})
}
errors={errors?.templateValues}
isLoadingValues={isTemplateLoading}
/>
}));
}}
errors={errors?.templateValues}
/>
{values.templateValues.type === 'app' && (
<AppTemplateContentField
values={values}
handleChange={handleChange}
errors={errors}
setValues={setValues}
/>
)}
{values.templateValues.type === 'custom' && (
<CustomTemplateContentField
values={values}
handleChange={handleChange}
errors={errors}
setValues={setValues}
/>
)}
</>
)}
{(method === editor.value || isTemplate) && !isTemplateLoading && (
{method === editor.value && (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
readonly={
method === edgeStackTemplate.value && !!customTemplate?.GitConfig
}
error={errors?.fileContent}
/>
)}
@ -154,3 +153,51 @@ export function DockerComposeForm({
}));
}
}
type TemplateContentFieldProps = {
values: DockerFormValues;
handleChange: (newValues: Partial<DockerFormValues>) => void;
errors?: FormikErrors<DockerFormValues>;
setValues: (values: SetStateAction<DockerFormValues>) => void;
};
function AppTemplateContentField({
values,
handleChange,
errors,
setValues,
}: TemplateContentFieldProps) {
const { isInitialLoading } = useRenderAppTemplate(
values.templateValues,
setValues
);
return (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
error={errors?.fileContent}
isLoading={isInitialLoading}
/>
);
}
function CustomTemplateContentField({
values,
handleChange,
errors,
setValues,
}: TemplateContentFieldProps) {
const { customTemplate, isInitialLoading } = useRenderCustomTemplate(
values.templateValues,
setValues
);
return (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
error={errors?.fileContent}
readonly={!!customTemplate?.GitConfig}
isLoading={isInitialLoading}
/>
);
}

View File

@ -1,3 +1,4 @@
import { InlineLoader } from '@@/InlineLoader';
import { WebEditorForm } from '@@/WebEditorForm';
export function DockerContentField({
@ -5,12 +6,18 @@ export function DockerContentField({
onChange,
readonly,
value,
isLoading,
}: {
value: string;
onChange: (value: string) => void;
error?: string;
readonly?: boolean;
isLoading?: boolean;
}) {
if (isLoading) {
return <InlineLoader>Loading stack content...</InlineLoader>;
}
return (
<WebEditorForm
id="stack-creation-editor"

View File

@ -56,6 +56,7 @@ export function InnerForm({
onChange={(value) => setFieldValue('name', value)}
value={values.name}
errors={errors.name}
placeholder="e.g. my-stack"
/>
<EdgeGroupsSelector
@ -128,13 +129,7 @@ export function InnerForm({
isEdit={false}
values={values.staggerConfig}
onChange={(newStaggerValues) =>
setValues((values) => ({
...values,
staggerConfig: {
...values.staggerConfig,
...newStaggerValues,
},
}))
setFieldValue('staggerConfig', newStaggerValues)
}
/>
</>

View File

@ -17,16 +17,19 @@ export function NameField({
onChange,
value,
errors,
placeholder,
}: {
onChange(value: string): void;
value: string;
errors?: FormikErrors<string>;
placeholder?: string;
}) {
return (
<FormControl inputId="name-input" label="Name" errors={errors} required>
<Input
id="name-input"
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
value={value}
required
data-cy="edgeStackCreate-nameInput"

View File

@ -1,4 +1,5 @@
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { EnvVarsValue } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
export type SelectedTemplateValue =
| { templateId: number; type: 'custom' }
@ -7,5 +8,5 @@ export type SelectedTemplateValue =
export type Values = {
variables: VariablesFieldValue;
envVars: Record<string, string>;
envVars: EnvVarsValue;
} & SelectedTemplateValue;

View File

@ -9,14 +9,14 @@ import { Values } from './types';
export function templateFieldsetValidation({
customVariablesDefinitions,
envVarDefinitions,
appTemplateVariablesDefinitions,
}: {
customVariablesDefinitions: VariableDefinition[];
envVarDefinitions: Array<TemplateEnv>;
customVariablesDefinitions: Array<VariableDefinition>;
appTemplateVariablesDefinitions: Array<TemplateEnv>;
}): SchemaOf<Values> {
return object({
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
envVars: envVarsFieldsetValidation(envVarDefinitions)
envVars: envVarsFieldsetValidation(appTemplateVariablesDefinitions)
.optional()
.when('type', {
is: 'app',

View File

@ -0,0 +1,101 @@
import { DefaultBodyType, HttpResponse } from 'msw';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { mockCodeMirror, renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;
let mockTemplateType: string;
// browser address
// /edge/stacks/new?templateId=54&templateType=app
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { templateId: mockTemplateId, templateType: mockTemplateType },
})),
}));
mockCodeMirror();
// expected form values
const expectedAppTemplatePayload = {
deploymentType: 0,
edgeGroups: [1],
name: 'my-stack',
envVars: [{ name: 'LICENSE_KEY', value: 'license-123' }],
prePullImage: false,
registries: [],
retryDeploy: false,
staggerConfig: {
StaggerOption: 1,
StaggerParallelOption: 1,
DeviceNumber: 1,
DeviceNumberStartFrom: 0,
DeviceNumberIncrementBy: 2,
Timeout: '',
UpdateDelay: '',
UpdateFailureAction: 1,
},
useManifestNamespaces: false,
stackFileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
test('The web editor should be visible for app templates', async () => {
setMockCreateStackUrlParams(54, 'app');
const { getByRole, getByLabelText } = renderCreateForm();
// Wait for the form to be rendered
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// the web editor should be visible
expect(getByLabelText('Web editor')).toBeVisible();
});
test('The form should submit the correct request body for a given app template', async () => {
setMockCreateStackUrlParams(54, 'app');
let requestBody: DefaultBodyType;
server.use(
http.post('/api/edge_stacks/create/string', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({});
})
);
const { getByRole, getByLabelText } = renderCreateForm();
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// fill in the name and select the docker edge group
const user = userEvent.setup();
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
await user.type(
getByRole('textbox', { name: 'License key *' }),
'license-123'
);
const selectElement = getByLabelText('Edge groups');
await selectEvent.select(selectElement, 'docker');
// submit the form
await user.click(getByRole('button', { name: /Deploy the stack/i }));
// verify the request body
await waitFor(() => {
expect(requestBody).toEqual(expectedAppTemplatePayload);
});
});
function setMockCreateStackUrlParams(templateId: number, templateType: string) {
mockTemplateId = templateId;
mockTemplateType = templateType;
}

View File

@ -0,0 +1,107 @@
import { DefaultBodyType, HttpResponse } from 'msw';
import { waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { mockCodeMirror, renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;
let mockTemplateType: string;
// browser address
// /edge/stacks/new?templateId=54&templateType=app
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { templateId: mockTemplateId, templateType: mockTemplateType },
})),
}));
mockCodeMirror();
const expectedCustomTemplatePayload = {
deploymentType: 0,
edgeGroups: [1],
name: 'my-stack',
envVars: [],
prePullImage: true,
registries: [1],
retryDeploy: true,
staggerConfig: {
StaggerOption: 2,
StaggerParallelOption: 1,
DeviceNumber: 1,
DeviceNumberStartFrom: 0,
DeviceNumberIncrementBy: 2,
Timeout: '3',
UpdateDelay: '3',
UpdateFailureAction: 3,
},
useManifestNamespaces: false,
repositoryUrl: 'https://github.com/testA113/nginx-public',
repositoryUsername: '',
repositoryReferenceName: 'refs/heads/main',
filePathInRepository: 'docker/voting.yaml',
repositoryAuthentication: false,
repositoryGitCredentialId: 0,
repositoryPassword: '',
filesystemPath: '/test',
supportRelativePath: true,
perDeviceConfigsGroupMatchType: 'file',
perDeviceConfigsMatchType: 'file',
perDeviceConfigsPath: 'test',
tlsSkipVerify: false,
autoUpdate: null,
};
test('The web editor should be visible for custom templates', async () => {
setMockCreateStackUrlParams(8, 'custom');
const { getByRole, getByLabelText } = renderCreateForm();
// Wait for the form to be rendered
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// the web editor should be visible
expect(getByLabelText('Web editor')).toBeVisible();
});
test('The form should submit the correct request body for a given custom template', async () => {
setMockCreateStackUrlParams(8, 'custom');
let requestBody: DefaultBodyType;
server.use(
http.post('/api/edge_stacks/create/repository', async ({ request }) => {
requestBody = await request.json();
return HttpResponse.json({});
})
);
const { getByRole, getByLabelText } = renderCreateForm();
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
// fill in the name and select the docker edge group
const user = userEvent.setup();
await user.type(getByRole('textbox', { name: 'Name *' }), 'my-stack');
const selectElement = getByLabelText('Edge groups');
await selectEvent.select(selectElement, 'docker');
// submit the form
await user.click(getByRole('button', { name: /Deploy the stack/i }));
// verify the request body
await waitFor(() => {
expect(requestBody).toEqual(expectedCustomTemplatePayload);
});
});
function setMockCreateStackUrlParams(templateId: number, templateType: string) {
mockTemplateId = templateId;
mockTemplateType = templateType;
}

View File

@ -0,0 +1,256 @@
import { HttpResponse } from 'msw';
import { render, waitFor } from '@testing-library/react';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { http, server } from '@/setup-tests/server';
import { CreateForm } from '../CreateForm';
// app templates request
// GET /api/templates
const appTemplatesResponseBody = {
version: '3',
templates: [
{
id: 54,
type: 3,
title: 'TOSIBOX Lock for Container',
description:
'Lock for Container brings secure connectivity inside your industrial IoT devices',
administrator_only: false,
image: '',
repository: {
url: 'https://github.com/portainer/templates',
stackfile: 'stacks/tosibox/docker-compose.yml',
},
stackFile: '',
logo: 'https://portainer-io-assets.sfo2.digitaloceanspaces.com/logos/tosibox.png',
env: [
{
name: 'LICENSE_KEY',
label: 'License key',
},
],
platform: 'linux',
categories: ['edge'],
},
],
};
// app template content request
// GET /api/templates/54/file
const appTemplateContentResponseBody = {
FileContent:
// eslint-disable-next-line no-template-curly-in-string
'version: "3.7"\nservices:\n tosibox-lock-for-container:\n container_name: tosibox-lock-for-container\n image: tosibox/lock-for-container:latest\n hostname: tb-lfc\n restart: unless-stopped\n cap_add:\n - NET_ADMIN\n - SYS_TIME\n - SYS_PTRACE\n ports:\n - 80\n networks:\n - tbnet\n volumes:\n - tosibox-lfc:/etc/tosibox/docker_volume\n environment:\n - LICENSE_KEY=${LICENSE_KEY}\nvolumes:\n tosibox-lfc:\n name: tosibox-lfc\nnetworks:\n tbnet:\n name: tbnet\n ipam:\n config:\n - subnet: 10.10.206.0/24\n',
};
const customTemplatesResponseBody = [
{
Id: 8,
Title: 'git-with-all',
Description: 'test',
ProjectPath: '/Users/aliharris/portainer-data-ee/custom_templates/8',
EntryPoint: '',
CreatedByUserId: 1,
Note: '',
Platform: 1,
Logo: '',
Type: 2,
ResourceControl: {
Id: 9,
ResourceId: '8',
SubResourceIds: [],
Type: 8,
UserAccesses: [
{
UserId: 1,
AccessLevel: 1,
},
],
TeamAccesses: [],
Public: false,
AdministratorsOnly: false,
System: false,
},
Variables: [],
GitConfig: {
URL: 'https://github.com/testA113/nginx-public',
ReferenceName: 'refs/heads/main',
ConfigFilePath: 'docker/voting.yaml',
Authentication: {
Username: '',
Password: '',
GitCredentialID: 0,
},
ConfigHash: '1db40a888e07da7d9455897aadd349d0bc83bd83',
TLSSkipVerify: false,
},
IsComposeFormat: false,
EdgeTemplate: true,
EdgeSettings: {
PrePullImage: true,
RetryDeploy: true,
PrivateRegistryId: 1,
RelativePathSettings: {
SupportRelativePath: true,
FilesystemPath: '/test',
SupportPerDeviceConfigs: true,
PerDeviceConfigsMatchType: 'file',
PerDeviceConfigsGroupMatchType: 'file',
PerDeviceConfigsPath: 'test',
},
StaggerConfig: {
StaggerOption: 2,
StaggerParallelOption: 1,
DeviceNumber: 1,
DeviceNumberStartFrom: 0,
DeviceNumberIncrementBy: 2,
Timeout: '3',
UpdateDelay: '3',
UpdateFailureAction: 3,
},
},
},
];
const edgeGroups = [
{
Id: 1,
Name: 'docker',
Dynamic: false,
TagIds: [],
Endpoints: [12],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [4],
TrustedEndpoints: [12],
},
{
Id: 2,
Name: 'kubernetes',
Dynamic: false,
TagIds: [],
Endpoints: [11],
PartialMatch: false,
HasEdgeStack: false,
HasEdgeJob: false,
EndpointTypes: [7],
TrustedEndpoints: [11],
},
];
const gitCredentials = [
{
id: 1,
userId: 1,
name: 'test',
username: 'portainer-test',
creationDate: 1732761658,
},
];
const registries = [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer-test',
Password: 'test',
ManagementConfiguration: {
Type: 6,
Authentication: true,
Username: 'portainer-test',
Password: 'test',
TLSConfig: {
TLS: false,
TLSSkipVerify: false,
},
Ecr: {
Region: '',
},
},
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {},
UserAccessPolicies: null,
TeamAccessPolicies: null,
AuthorizedUsers: null,
AuthorizedTeams: null,
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
];
mockCodeMirror();
test('The form should render', async () => {
const { getByRole } = renderCreateForm();
// Wait for the form to be rendered
await waitFor(() => {
expect(getByRole('form')).toBeInTheDocument();
});
});
export function mockCodeMirror() {
vi.mock('@uiw/react-codemirror', () => ({
__esModule: true,
default: () => <div />,
}));
}
export function renderCreateForm() {
// user declaration needs to go at the start for user id related requests (e.g. git credentials)
const user = new UserViewModel({ Username: 'user' });
server.use(
http.get('/api/templates', () =>
HttpResponse.json(appTemplatesResponseBody)
)
);
server.use(
http.get('/api/custom_templates', () =>
HttpResponse.json(customTemplatesResponseBody)
)
);
server.use(
http.get('/api/custom_templates/8', () =>
HttpResponse.json(customTemplatesResponseBody[0])
)
);
server.use(
http.post('/api/templates/54/file', () =>
HttpResponse.json(appTemplateContentResponseBody)
)
);
server.use(http.get('/api/edge_stacks', () => HttpResponse.json([])));
server.use(http.get('/api/edge_groups', () => HttpResponse.json(edgeGroups)));
server.use(http.get('/api/registries', () => HttpResponse.json(registries)));
server.use(
http.get('/api/users/1/gitcredentials', () =>
HttpResponse.json(gitCredentials)
)
);
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateForm), user)
);
return render(<Wrapped />);
}

View File

@ -29,45 +29,36 @@ export function useRenderAppTemplate(
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
enabled: templateValues.type === 'app',
});
const [renderedFile, setRenderedFile] = useState<string>('');
useEffect(() => {
if (templateFileQuery.data) {
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
[]
);
if (newFile !== renderedFile) {
setRenderedFile(newFile);
setValues((values) => ({
...values,
fileContent: newFile,
}));
}
}
}, [
renderedFile,
setValues,
template,
templateFileQuery.data,
templateValues.variables,
]);
const [currentTemplateId, setCurrentTemplateId] = useState<
number | undefined
>(templateValues.templateId);
useEffect(() => {
if (template?.Id !== currentTemplateId) {
if (templateValues.type === 'app' && templateFileQuery.data) {
const newTemplateValues = getValuesFromAppTemplate(template);
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
[]
);
setCurrentTemplateId(template?.Id);
setValues((values) => ({
...values,
...getValuesFromAppTemplate(template),
...newTemplateValues,
fileContent: newFile,
}));
}
}, [currentTemplateId, setValues, template]);
}, [
currentTemplateId,
setValues,
template,
templateFileQuery.data,
templateFileQuery.isInitialLoading,
templateValues.type,
templateValues.variables,
]);
return {
appTemplate: template,

View File

@ -29,45 +29,36 @@ export function useRenderCustomTemplate(
enabled: templateValues.type === 'custom',
}
);
const [renderedFile, setRenderedFile] = useState<string>('');
useEffect(() => {
if (templateFileQuery.data) {
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
template?.Variables || []
);
if (newFile !== renderedFile) {
setRenderedFile(newFile);
setValues((values) => ({
...values,
fileContent: newFile,
}));
}
}
}, [
renderedFile,
setValues,
template,
templateFileQuery.data,
templateValues.variables,
]);
const [currentTemplateId, setCurrentTemplateId] = useState<
number | undefined
>(templateValues.templateId);
useEffect(() => {
if (template?.Id !== currentTemplateId) {
if (templateValues.type === 'custom' && templateFileQuery.data) {
const newTemplateValues = getValuesFromTemplate(template);
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
template?.Variables || []
);
setCurrentTemplateId(template?.Id);
setValues((values) => ({
...values,
...getValuesFromTemplate(template),
...newTemplateValues,
fileContent: newFile,
}));
}
}, [currentTemplateId, setValues, template]);
}, [
currentTemplateId,
setValues,
template,
templateFileQuery.data,
templateFileQuery.isInitialLoading,
templateValues.type,
templateValues.variables,
]);
return {
customTemplate: template,

View File

@ -1,43 +1,29 @@
import { useRouter } from '@uirouter/react';
import { useParamState } from '@/react/hooks/useParamState';
import { useParamsState } from '@/react/hooks/useParamState';
export function useTemplateParams() {
const router = useRouter();
const [id] = useParamState('templateId', (param) => {
if (!param) {
return undefined;
}
const [{ id, type }, setTemplateParams] = useParamsState(
['templateId', 'templateType'],
(params) => ({
id: parseTemplateId(params.templateId),
type: parseTemplateType(params.templateType),
})
);
const templateId = parseInt(param, 10);
if (Number.isNaN(templateId)) {
return undefined;
}
return templateId;
});
const [type] = useParamState('templateType', (param) => {
if (param === 'app' || param === 'custom') {
return param;
}
return undefined;
});
return [{ id, type }, handleChange] as const;
function handleChange({
id,
type,
}: {
id: number | undefined;
type: 'app' | 'custom' | undefined;
}) {
router.stateService.go(
'.',
{ templateId: id, templateType: type },
{ reload: false }
);
}
return [{ id, type }, setTemplateParams] as const;
}
function parseTemplateId(param?: string) {
if (!param) {
return undefined;
}
return parseInt(param, 10);
}
function parseTemplateType(param?: string): 'app' | 'custom' | undefined {
if (param === 'app' || param === 'custom') {
return param;
}
return undefined;
}

View File

@ -1,4 +1,5 @@
import _ from 'lodash';
import { useEffect } from 'react';
import { notifyError } from '@/portainer/services/notifications';
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
@ -31,6 +32,11 @@ export function PrivateRegistryFieldsetWrapper({
const registriesQuery = useRegistries({ hideDefault: true });
useEffect(() => {
matchRegistry(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values.file, values.fileContent]);
if (!registriesQuery.data) {
return null;
}

View File

@ -1,6 +1,5 @@
import { number, string, object, SchemaOf } from 'yup';
import { FormikErrors } from 'formik';
import { useState, useEffect } from 'react';
import { FormSection } from '@@/form-components/FormSection';
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
@ -36,19 +35,11 @@ const staggerOptions = [
] as const;
export function StaggerFieldset({
values: initialValue,
values,
onChange,
errors,
isEdit = true,
}: Props) {
const [values, setControlledValues] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
useEffect(() => {
if (!!initialValue && initialValue.StaggerOption !== values.StaggerOption) {
setControlledValues(initialValue);
}
}, [initialValue, values]);
return (
<FormSection title="Update configurations">
{!isEdit && (
@ -208,7 +199,7 @@ export function StaggerFieldset({
function handleChange(partialValue: Partial<StaggerConfig>) {
onChange(partialValue);
setControlledValues((values) => ({ ...values, ...partialValue }));
// setControlledValues((values) => ({ ...values, ...partialValue }));
}
}

View File

@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import { RegistryId } from '@/react/portainer/registries/types/registry';
import axios, {
json2formData,
@ -11,7 +11,7 @@ import { buildUrl } from './buildUrl';
export function useParseRegistries() {
return useMutation(parseRegistries, {
...withError('Failed parsing registries'),
...withGlobalError('Failed parsing registries'),
});
}
@ -23,7 +23,7 @@ export async function parseRegistries({
fileContent?: string;
}) {
if (!file && !fileContent) {
throw new Error('File or fileContent must be provided');
return [];
}
let currentFile = file;

View File

@ -1,5 +1,6 @@
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
/** Only use when you need to use/update a single param at a time. Using this to update multiple params will cause the state to get out of sync. */
export function useParamState<T>(
param: string,
parseParam: (param: string | undefined) => T | undefined = (param) =>
@ -18,3 +19,36 @@ export function useParamState<T>(
},
] as const;
}
/** Use this when you need to use/update multiple params at once. */
export function useParamsState<T extends Record<string, unknown>>(
params: string[],
parseParams: (params: Record<string, string | undefined>) => T
) {
const { params: stateParams } = useCurrentStateAndParams();
const router = useRouter();
const state = parseParams(
params.reduce(
(acc, param) => {
acc[param] = stateParams[param];
return acc;
},
{} as Record<string, string | undefined>
)
);
function setState(newState: Partial<T>) {
const newStateParams = Object.entries(newState).reduce(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{} as Record<string, unknown>
);
router.stateService.go('.', newStateParams, { reload: false });
}
return [state, setState] as const;
}

View File

@ -78,6 +78,7 @@ export function RelativePathFieldset({
<FormControl
label="Local filesystem path"
errors={errors?.FilesystemPath}
required
>
<Input
name="FilesystemPath"
@ -142,6 +143,7 @@ export function RelativePathFieldset({
<FormControl
label="Local filesystem path"
errors={errors?.FilesystemPath}
required
>
<Input
name="FilesystemPath"
@ -174,6 +176,7 @@ export function RelativePathFieldset({
label="Directory"
errors={errors?.PerDeviceConfigsPath}
inputId="per_device_configs_path_input"
required
>
<PathSelector
value={value.PerDeviceConfigsPath || ''}

View File

@ -13,7 +13,7 @@ export function relativePathValidation(): SchemaOf<RelativePathModel> {
.default(''),
SupportPerDeviceConfigs: boolean().default(false),
PerDeviceConfigsPath: string()
.when(['SupportPerDeviceConfigs'], {
.when('SupportPerDeviceConfigs', {
is: true,
then: string().required('Directory is required'),
})