mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
fix(edge-stacks): various custom template issues [BE-11414] (#189)
This commit is contained in:
parent
16a1825990
commit
97e7a3c5e2
24 changed files with 749 additions and 374 deletions
|
@ -74,6 +74,10 @@ angular
|
||||||
data: {
|
data: {
|
||||||
docs: '/user/edge/stacks/add',
|
docs: '/user/edge/stacks/add',
|
||||||
},
|
},
|
||||||
|
params: {
|
||||||
|
templateId: { dynamic: true },
|
||||||
|
templateType: { dynamic: true },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const stacksEdit = {
|
const stacksEdit = {
|
||||||
|
|
|
@ -271,7 +271,7 @@
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm !ml-0"
|
||||||
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
|
ng-disabled="$ctrl.isUpdateButtonDisabled() || editRegistry.$invalid"
|
||||||
ng-click="$ctrl.updateRegistry()"
|
ng-click="$ctrl.updateRegistry()"
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
button-spinner="$ctrl.state.actionInProgress"
|
||||||
|
|
|
@ -14,7 +14,7 @@ export function RadioGroup<T extends string | number = string>({
|
||||||
onOptionChange,
|
onOptionChange,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
import { toGitFormModel } from '@/react/portainer/gitops/types';
|
||||||
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
|
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
|
||||||
|
@ -18,12 +18,12 @@ import { DeploymentType } from '../types';
|
||||||
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||||
|
|
||||||
import { InnerForm } from './InnerForm';
|
import { InnerForm } from './InnerForm';
|
||||||
import { FormValues } from './types';
|
|
||||||
import { useValidation } from './CreateForm.validation';
|
import { useValidation } from './CreateForm.validation';
|
||||||
import { Values as TemplateValues } from './TemplateFieldset/types';
|
import { Values as TemplateValues } from './TemplateFieldset/types';
|
||||||
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
|
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
|
||||||
import { useTemplateParams } from './useTemplateParams';
|
import { useTemplateParams } from './useTemplateParams';
|
||||||
import { useCreate } from './useCreate';
|
import { useCreate } from './useCreate';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
export function CreateForm() {
|
export function CreateForm() {
|
||||||
const [webhookId] = useState(() => createWebhookId());
|
const [webhookId] = useState(() => createWebhookId());
|
||||||
|
@ -38,33 +38,12 @@ export function CreateForm() {
|
||||||
templateType: templateParams.type,
|
templateType: templateParams.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
const initialValues = useInitialValues(templateQuery, templateParams);
|
||||||
templateParams.id &&
|
|
||||||
!(templateQuery.customTemplate || templateQuery.appTemplate)
|
if (!initialValues) {
|
||||||
) {
|
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
|
@ -128,7 +107,66 @@ function useTemplate(
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appTemplate: appTemplateQuery.data,
|
appTemplate: type === 'app' ? appTemplateQuery.data : undefined,
|
||||||
customTemplate: customTemplateQuery.data,
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -117,7 +117,7 @@ export function useValidation({
|
||||||
}),
|
}),
|
||||||
templateValues: templateFieldsetValidation({
|
templateValues: templateFieldsetValidation({
|
||||||
customVariablesDefinitions: customTemplate?.Variables || [],
|
customVariablesDefinitions: customTemplate?.Variables || [],
|
||||||
envVarDefinitions: appTemplate?.Env || [],
|
appTemplateVariablesDefinitions: appTemplate?.Env || [],
|
||||||
}),
|
}),
|
||||||
git: mixed().when('method', {
|
git: mixed().when('method', {
|
||||||
is: 'repository',
|
is: 'repository',
|
||||||
|
|
|
@ -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 { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||||
|
@ -24,31 +25,18 @@ import { useRenderAppTemplate } from './useRenderAppTemplate';
|
||||||
|
|
||||||
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||||
|
|
||||||
export function DockerComposeForm({
|
interface Props {
|
||||||
webhookId,
|
|
||||||
onChangeTemplate,
|
|
||||||
}: {
|
|
||||||
webhookId: string;
|
webhookId: string;
|
||||||
onChangeTemplate: ({
|
onChangeTemplate: (change: {
|
||||||
type,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
type: 'app' | 'custom' | undefined;
|
type: 'app' | 'custom' | undefined;
|
||||||
id: number | undefined;
|
id: number | undefined;
|
||||||
}) => void;
|
}) => void;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||||
const { method } = values;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormSection title="Build Method">
|
<FormSection title="Build Method">
|
||||||
|
@ -62,10 +50,10 @@ export function DockerComposeForm({
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
{method === edgeStackTemplate.value && (
|
{method === edgeStackTemplate.value && (
|
||||||
|
<>
|
||||||
<TemplateFieldset
|
<TemplateFieldset
|
||||||
values={values.templateValues}
|
values={values.templateValues}
|
||||||
setValues={(templateAction) =>
|
setValues={(templateAction) => {
|
||||||
setValues((values) => {
|
|
||||||
const templateValues = applySetStateAction(
|
const templateValues = applySetStateAction(
|
||||||
templateAction,
|
templateAction,
|
||||||
values.templateValues
|
values.templateValues
|
||||||
|
@ -74,25 +62,36 @@ export function DockerComposeForm({
|
||||||
id: templateValues.templateId,
|
id: templateValues.templateId,
|
||||||
type: templateValues.type,
|
type: templateValues.type,
|
||||||
});
|
});
|
||||||
|
setValues((values) => ({
|
||||||
return {
|
|
||||||
...values,
|
...values,
|
||||||
templateValues,
|
templateValues,
|
||||||
};
|
}));
|
||||||
})
|
}}
|
||||||
}
|
|
||||||
errors={errors?.templateValues}
|
errors={errors?.templateValues}
|
||||||
isLoadingValues={isTemplateLoading}
|
/>
|
||||||
|
{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
|
<DockerContentField
|
||||||
value={values.fileContent}
|
value={values.fileContent}
|
||||||
onChange={(value) => handleChange({ fileContent: value })}
|
onChange={(value) => handleChange({ fileContent: value })}
|
||||||
readonly={
|
|
||||||
method === edgeStackTemplate.value && !!customTemplate?.GitConfig
|
|
||||||
}
|
|
||||||
error={errors?.fileContent}
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { InlineLoader } from '@@/InlineLoader';
|
||||||
import { WebEditorForm } from '@@/WebEditorForm';
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
|
||||||
export function DockerContentField({
|
export function DockerContentField({
|
||||||
|
@ -5,12 +6,18 @@ export function DockerContentField({
|
||||||
onChange,
|
onChange,
|
||||||
readonly,
|
readonly,
|
||||||
value,
|
value,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <InlineLoader>Loading stack content...</InlineLoader>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebEditorForm
|
<WebEditorForm
|
||||||
id="stack-creation-editor"
|
id="stack-creation-editor"
|
||||||
|
|
|
@ -56,6 +56,7 @@ export function InnerForm({
|
||||||
onChange={(value) => setFieldValue('name', value)}
|
onChange={(value) => setFieldValue('name', value)}
|
||||||
value={values.name}
|
value={values.name}
|
||||||
errors={errors.name}
|
errors={errors.name}
|
||||||
|
placeholder="e.g. my-stack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeGroupsSelector
|
<EdgeGroupsSelector
|
||||||
|
@ -128,13 +129,7 @@ export function InnerForm({
|
||||||
isEdit={false}
|
isEdit={false}
|
||||||
values={values.staggerConfig}
|
values={values.staggerConfig}
|
||||||
onChange={(newStaggerValues) =>
|
onChange={(newStaggerValues) =>
|
||||||
setValues((values) => ({
|
setFieldValue('staggerConfig', newStaggerValues)
|
||||||
...values,
|
|
||||||
staggerConfig: {
|
|
||||||
...values.staggerConfig,
|
|
||||||
...newStaggerValues,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -17,16 +17,19 @@ export function NameField({
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
errors,
|
errors,
|
||||||
|
placeholder,
|
||||||
}: {
|
}: {
|
||||||
onChange(value: string): void;
|
onChange(value: string): void;
|
||||||
value: string;
|
value: string;
|
||||||
errors?: FormikErrors<string>;
|
errors?: FormikErrors<string>;
|
||||||
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FormControl inputId="name-input" label="Name" errors={errors} required>
|
<FormControl inputId="name-input" label="Name" errors={errors} required>
|
||||||
<Input
|
<Input
|
||||||
id="name-input"
|
id="name-input"
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
required
|
required
|
||||||
data-cy="edgeStackCreate-nameInput"
|
data-cy="edgeStackCreate-nameInput"
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||||
|
import { EnvVarsValue } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
|
||||||
|
|
||||||
export type SelectedTemplateValue =
|
export type SelectedTemplateValue =
|
||||||
| { templateId: number; type: 'custom' }
|
| { templateId: number; type: 'custom' }
|
||||||
|
@ -7,5 +8,5 @@ export type SelectedTemplateValue =
|
||||||
|
|
||||||
export type Values = {
|
export type Values = {
|
||||||
variables: VariablesFieldValue;
|
variables: VariablesFieldValue;
|
||||||
envVars: Record<string, string>;
|
envVars: EnvVarsValue;
|
||||||
} & SelectedTemplateValue;
|
} & SelectedTemplateValue;
|
||||||
|
|
|
@ -9,14 +9,14 @@ import { Values } from './types';
|
||||||
|
|
||||||
export function templateFieldsetValidation({
|
export function templateFieldsetValidation({
|
||||||
customVariablesDefinitions,
|
customVariablesDefinitions,
|
||||||
envVarDefinitions,
|
appTemplateVariablesDefinitions,
|
||||||
}: {
|
}: {
|
||||||
customVariablesDefinitions: VariableDefinition[];
|
customVariablesDefinitions: Array<VariableDefinition>;
|
||||||
envVarDefinitions: Array<TemplateEnv>;
|
appTemplateVariablesDefinitions: Array<TemplateEnv>;
|
||||||
}): SchemaOf<Values> {
|
}): SchemaOf<Values> {
|
||||||
return object({
|
return object({
|
||||||
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
|
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
|
||||||
envVars: envVarsFieldsetValidation(envVarDefinitions)
|
envVars: envVarsFieldsetValidation(appTemplateVariablesDefinitions)
|
||||||
.optional()
|
.optional()
|
||||||
.when('type', {
|
.when('type', {
|
||||||
is: 'app',
|
is: 'app',
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
256
app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx
Normal file
256
app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx
Normal 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 />);
|
||||||
|
}
|
|
@ -29,45 +29,36 @@ export function useRenderAppTemplate(
|
||||||
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
|
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
|
||||||
enabled: templateValues.type === 'app',
|
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<
|
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||||
number | undefined
|
number | undefined
|
||||||
>(templateValues.templateId);
|
>(templateValues.templateId);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setCurrentTemplateId(template?.Id);
|
||||||
setValues((values) => ({
|
setValues((values) => ({
|
||||||
...values,
|
...values,
|
||||||
...getValuesFromAppTemplate(template),
|
...newTemplateValues,
|
||||||
|
fileContent: newFile,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [currentTemplateId, setValues, template]);
|
}, [
|
||||||
|
currentTemplateId,
|
||||||
|
setValues,
|
||||||
|
template,
|
||||||
|
templateFileQuery.data,
|
||||||
|
templateFileQuery.isInitialLoading,
|
||||||
|
templateValues.type,
|
||||||
|
templateValues.variables,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appTemplate: template,
|
appTemplate: template,
|
||||||
|
|
|
@ -29,45 +29,36 @@ export function useRenderCustomTemplate(
|
||||||
enabled: templateValues.type === 'custom',
|
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<
|
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||||
number | undefined
|
number | undefined
|
||||||
>(templateValues.templateId);
|
>(templateValues.templateId);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setCurrentTemplateId(template?.Id);
|
||||||
setValues((values) => ({
|
setValues((values) => ({
|
||||||
...values,
|
...values,
|
||||||
...getValuesFromTemplate(template),
|
...newTemplateValues,
|
||||||
|
fileContent: newFile,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [currentTemplateId, setValues, template]);
|
}, [
|
||||||
|
currentTemplateId,
|
||||||
|
setValues,
|
||||||
|
template,
|
||||||
|
templateFileQuery.data,
|
||||||
|
templateFileQuery.isInitialLoading,
|
||||||
|
templateValues.type,
|
||||||
|
templateValues.variables,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customTemplate: template,
|
customTemplate: template,
|
||||||
|
|
|
@ -1,43 +1,29 @@
|
||||||
import { useRouter } from '@uirouter/react';
|
import { useParamsState } from '@/react/hooks/useParamState';
|
||||||
|
|
||||||
import { useParamState } from '@/react/hooks/useParamState';
|
|
||||||
|
|
||||||
export function useTemplateParams() {
|
export function useTemplateParams() {
|
||||||
const router = useRouter();
|
const [{ id, type }, setTemplateParams] = useParamsState(
|
||||||
const [id] = useParamState('templateId', (param) => {
|
['templateId', 'templateType'],
|
||||||
|
(params) => ({
|
||||||
|
id: parseTemplateId(params.templateId),
|
||||||
|
type: parseTemplateType(params.templateType),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return [{ id, type }, setTemplateParams] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTemplateId(param?: string) {
|
||||||
if (!param) {
|
if (!param) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = parseInt(param, 10);
|
return parseInt(param, 10);
|
||||||
if (Number.isNaN(templateId)) {
|
}
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return templateId;
|
function parseTemplateType(param?: string): 'app' | 'custom' | undefined {
|
||||||
});
|
|
||||||
|
|
||||||
const [type] = useParamState('templateType', (param) => {
|
|
||||||
if (param === 'app' || param === 'custom') {
|
if (param === 'app' || param === 'custom') {
|
||||||
return param;
|
return param;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
import { notifyError } from '@/portainer/services/notifications';
|
||||||
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
|
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
|
||||||
|
@ -31,6 +32,11 @@ export function PrivateRegistryFieldsetWrapper({
|
||||||
|
|
||||||
const registriesQuery = useRegistries({ hideDefault: true });
|
const registriesQuery = useRegistries({ hideDefault: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
matchRegistry(values);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [values.file, values.fileContent]);
|
||||||
|
|
||||||
if (!registriesQuery.data) {
|
if (!registriesQuery.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { number, string, object, SchemaOf } from 'yup';
|
import { number, string, object, SchemaOf } from 'yup';
|
||||||
import { FormikErrors } from 'formik';
|
import { FormikErrors } from 'formik';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { FormSection } from '@@/form-components/FormSection';
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
|
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
|
||||||
|
@ -36,19 +35,11 @@ const staggerOptions = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function StaggerFieldset({
|
export function StaggerFieldset({
|
||||||
values: initialValue,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
errors,
|
errors,
|
||||||
isEdit = true,
|
isEdit = true,
|
||||||
}: Props) {
|
}: 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 (
|
return (
|
||||||
<FormSection title="Update configurations">
|
<FormSection title="Update configurations">
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
|
@ -208,7 +199,7 @@ export function StaggerFieldset({
|
||||||
|
|
||||||
function handleChange(partialValue: Partial<StaggerConfig>) {
|
function handleChange(partialValue: Partial<StaggerConfig>) {
|
||||||
onChange(partialValue);
|
onChange(partialValue);
|
||||||
setControlledValues((values) => ({ ...values, ...partialValue }));
|
// setControlledValues((values) => ({ ...values, ...partialValue }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
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 { RegistryId } from '@/react/portainer/registries/types/registry';
|
||||||
import axios, {
|
import axios, {
|
||||||
json2formData,
|
json2formData,
|
||||||
|
@ -11,7 +11,7 @@ import { buildUrl } from './buildUrl';
|
||||||
|
|
||||||
export function useParseRegistries() {
|
export function useParseRegistries() {
|
||||||
return useMutation(parseRegistries, {
|
return useMutation(parseRegistries, {
|
||||||
...withError('Failed parsing registries'),
|
...withGlobalError('Failed parsing registries'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export async function parseRegistries({
|
||||||
fileContent?: string;
|
fileContent?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!file && !fileContent) {
|
if (!file && !fileContent) {
|
||||||
throw new Error('File or fileContent must be provided');
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentFile = file;
|
let currentFile = file;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
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>(
|
export function useParamState<T>(
|
||||||
param: string,
|
param: string,
|
||||||
parseParam: (param: string | undefined) => T | undefined = (param) =>
|
parseParam: (param: string | undefined) => T | undefined = (param) =>
|
||||||
|
@ -18,3 +19,36 @@ export function useParamState<T>(
|
||||||
},
|
},
|
||||||
] as const;
|
] 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;
|
||||||
|
}
|
||||||
|
|
|
@ -78,6 +78,7 @@ export function RelativePathFieldset({
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Local filesystem path"
|
label="Local filesystem path"
|
||||||
errors={errors?.FilesystemPath}
|
errors={errors?.FilesystemPath}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="FilesystemPath"
|
name="FilesystemPath"
|
||||||
|
@ -142,6 +143,7 @@ export function RelativePathFieldset({
|
||||||
<FormControl
|
<FormControl
|
||||||
label="Local filesystem path"
|
label="Local filesystem path"
|
||||||
errors={errors?.FilesystemPath}
|
errors={errors?.FilesystemPath}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
name="FilesystemPath"
|
name="FilesystemPath"
|
||||||
|
@ -174,6 +176,7 @@ export function RelativePathFieldset({
|
||||||
label="Directory"
|
label="Directory"
|
||||||
errors={errors?.PerDeviceConfigsPath}
|
errors={errors?.PerDeviceConfigsPath}
|
||||||
inputId="per_device_configs_path_input"
|
inputId="per_device_configs_path_input"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<PathSelector
|
<PathSelector
|
||||||
value={value.PerDeviceConfigsPath || ''}
|
value={value.PerDeviceConfigsPath || ''}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export function relativePathValidation(): SchemaOf<RelativePathModel> {
|
||||||
.default(''),
|
.default(''),
|
||||||
SupportPerDeviceConfigs: boolean().default(false),
|
SupportPerDeviceConfigs: boolean().default(false),
|
||||||
PerDeviceConfigsPath: string()
|
PerDeviceConfigsPath: string()
|
||||||
.when(['SupportPerDeviceConfigs'], {
|
.when('SupportPerDeviceConfigs', {
|
||||||
is: true,
|
is: true,
|
||||||
then: string().required('Directory is required'),
|
then: string().required('Directory is required'),
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue