1
0
Fork 0
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:
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: { data: {
docs: '/user/edge/stacks/add', docs: '/user/edge/stacks/add',
}, },
params: {
templateId: { dynamic: true },
templateType: { dynamic: true },
},
}; };
const stacksEdit = { const stacksEdit = {

View file

@ -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"

View file

@ -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}

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 { 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;
}

View file

@ -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',

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 { 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}
/>
);
}

View file

@ -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"

View file

@ -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,
},
}))
} }
/> />
</> </>

View file

@ -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"

View file

@ -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;

View file

@ -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',

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, { 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,

View file

@ -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,

View file

@ -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 }
);
}
} }

View file

@ -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;
} }

View file

@ -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 }));
} }
} }

View file

@ -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;

View 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;
}

View file

@ -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 || ''}

View file

@ -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'),
}) })