1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

refactor(edge/stacks): migrate create view to react [EE-2223] (#11575)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Chaim Lev-Ari 2024-05-06 08:08:03 +03:00 committed by GitHub
parent f22aed34b5
commit 8a81d95253
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 1878 additions and 1005 deletions

View file

@ -0,0 +1,133 @@
import { Formik } from 'formik';
import { useState } from 'react';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { getDefaultValues as getEnvVarDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { Widget } from '@@/Widget';
import { DeploymentType } from '../types';
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { InnerForm } from './InnerForm';
import { FormValues } from './types';
import { useValidation } from './CreateForm.validation';
import { Values as TemplateValues } from './TemplateFieldset/types';
import { getInitialTemplateValues } from './TemplateFieldset/TemplateFieldset';
import { useTemplateParams } from './useTemplateParams';
import { useCreate } from './useCreate';
export function CreateForm() {
const [webhookId] = useState(() => createWebhookId());
const [templateParams, setTemplateParams] = useTemplateParams();
const templateQuery = useTemplate(templateParams.type, templateParams.id);
const validation = useValidation(templateQuery);
const mutation = useCreate({
webhookId,
template: templateQuery.customTemplate || templateQuery.appTemplate,
templateType: templateParams.type,
});
if (
templateParams.id &&
!(templateQuery.customTemplate || templateQuery.appTemplate)
) {
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(),
relativePath: getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
useManifestNamespaces: false,
};
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Body>
<Formik
initialValues={initialValues}
onSubmit={mutation.onSubmit}
validationSchema={validation}
>
<InnerForm
webhookId={webhookId}
isLoading={mutation.isLoading}
onChangeTemplate={setTemplateParams}
/>
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
);
}
function getTemplateValues(
type: 'custom' | 'app' | undefined,
template: TemplateViewModel | CustomTemplate | undefined
): TemplateValues {
if (!type) {
return getInitialTemplateValues();
}
if (type === 'custom') {
const customTemplate = template as CustomTemplate;
return {
templateId: customTemplate.Id,
type,
variables: getVariablesFieldDefaultValues(customTemplate.Variables),
envVars: {},
};
}
const appTemplate = template as TemplateViewModel;
return {
templateId: appTemplate.Id,
type,
variables: [],
envVars: getEnvVarDefaultValues(appTemplate.Env),
};
}
function useTemplate(
type: 'app' | 'custom' | undefined,
id: number | undefined
) {
const customTemplateQuery = useCustomTemplate(id, {
enabled: !!id && type === 'custom',
});
const appTemplateQuery = useAppTemplate(id, {
enabled: !!id && type === 'app',
});
return {
appTemplate: appTemplateQuery.data,
customTemplate: customTemplateQuery.data,
};
}

View file

@ -0,0 +1,90 @@
import {
SchemaOf,
array,
boolean,
lazy,
mixed,
number,
object,
string,
} from 'yup';
import { useMemo } from 'react';
import Lazy from 'yup/lib/Lazy';
import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
import { useCurrentUser } from '@/react/hooks/useUser';
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
import { file } from '@@/form-components/yup-file-validation';
import { DeploymentType } from '../types';
import { staggerConfigValidation } from '../components/StaggerFieldset';
import { FormValues, Method } from './types';
import { templateFieldsetValidation } from './TemplateFieldset/validation';
import { useNameValidation } from './NameField';
export function useValidation({
appTemplate,
customTemplate,
}: {
appTemplate: TemplateViewModel | undefined;
customTemplate: CustomTemplate | undefined;
}): Lazy<SchemaOf<FormValues>> {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const nameValidation = useNameValidation();
return useMemo(
() =>
lazy((values: FormValues) =>
object({
method: mixed<Method>()
.oneOf(['editor', 'upload', 'repository', 'template'])
.required(),
name: nameValidation(values.groupIds),
groupIds: array(number().required())
.required()
.min(1, 'At least one Edge group is required'),
deploymentType: mixed<DeploymentType>()
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
.required(),
envVars: envVarValidation(),
privateRegistryId: number().default(0),
prePullImage: boolean().default(false),
retryDeploy: boolean().default(false),
enableWebhook: boolean().default(false),
staggerConfig: staggerConfigValidation(),
fileContent: string()
.default('')
.when('method', {
is: 'editor',
then: (schema) => schema.required('Config file is required'),
}),
file: file().when('method', {
is: 'upload',
then: (schema) => schema.required(),
}),
templateValues: templateFieldsetValidation({
customVariablesDefinitions: customTemplate?.Variables || [],
envVarDefinitions: appTemplate?.Env || [],
}),
git: mixed().when('method', {
is: 'repository',
then: buildGitValidationSchema(
gitCredentialsQuery.data || [],
!!customTemplate
),
}) as SchemaOf<GitFormModel>,
relativePath: relativePathValidation(),
useManifestNamespaces: boolean().default(false),
})
),
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
);
}

View file

@ -0,0 +1,20 @@
import { PageHeader } from '@@/PageHeader';
import { CreateForm } from './CreateForm';
export function CreateView() {
return (
<>
<PageHeader
title="Create Edge stack"
breadcrumbs={[
{ label: 'Edge Stacks', link: 'edge.stacks' },
'Create Edge stack',
]}
reload
/>
<CreateForm />
</>
);
}

View file

@ -0,0 +1,43 @@
import { SwitchField } from '@@/form-components/SwitchField';
import { FormValues } from './types';
export function DeploymentOptions({
setFieldValue,
values,
}: {
values: FormValues;
setFieldValue: <T>(field: string, value: T) => void;
}) {
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
checked={values.prePullImage}
name="prePullImage"
label="Pre-pull images"
tooltip="When enabled, the image will be pre-pulled before deployment is started. This is useful in scenarios where the image download may be delayed or intermittent and would subsequently cause the deployment to fail"
labelClass="col-sm-3 col-lg-2"
onChange={(value) => setFieldValue('prePullImage', value)}
data-cy="pre-pull-images-switch"
/>
</div>
</div>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
checked={values.retryDeploy}
name="retryDeploy"
label="Retry deployment"
tooltip="When enabled, this will allow the edge agent to retry deployment if failed to deploy initially"
labelClass="col-sm-3 col-lg-2"
onChange={(value) => setFieldValue('retryDeploy', value)}
data-cy="retry-deployment-switch"
/>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,157 @@
import { useFormikContext } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { BoxSelector } from '@@/BoxSelector';
import { FormSection } from '@@/form-components/FormSection';
import {
editor,
git,
edgeStackTemplate,
upload,
} from '@@/BoxSelector/common-options/build-methods';
import { WebEditorForm } from '@@/WebEditorForm';
import { FileUploadForm } from '@@/form-components/FileUpload';
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
import { useRenderTemplate } from './useRenderTemplate';
import { DockerFormValues } from './types';
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
export function DockerComposeForm({
webhookId,
onChangeTemplate,
}: {
webhookId: string;
onChangeTemplate: ({
type,
id,
}: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
}) => void;
}) {
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
const { method } = values;
const template = useRenderTemplate(values.templateValues, setValues);
return (
<>
<FormSection title="Build Method">
<BoxSelector
options={buildMethods}
onChange={(value) => handleChange({ method: value })}
value={method}
radioName="method"
slim
/>
</FormSection>
{method === edgeStackTemplate.value && (
<TemplateFieldset
values={values.templateValues}
setValues={(templateAction) =>
setValues((values) => {
const templateValues = applySetStateAction(
templateAction,
values.templateValues
);
onChangeTemplate({
id: templateValues.templateId,
type: templateValues.type,
});
return {
...values,
templateValues,
};
})
}
errors={errors?.templateValues}
/>
)}
{(method === editor.value ||
(method === edgeStackTemplate.value && template)) && (
<WebEditorForm
id="stack-creation-editor"
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
yaml
placeholder="Define or paste the content of your docker compose file here"
error={errors?.fileContent}
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
data-cy="stack-creation-editor"
>
You can get more information about Compose file format in the{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</WebEditorForm>
)}
{method === upload.value && (
<FileUploadForm
value={values.file}
onChange={(File) => handleChange({ file: File })}
required
description="You can upload a Compose file from your computer."
data-cy="stack-creation-file-upload"
/>
)}
{method === git.value && (
<>
<GitForm
errors={errors?.git}
value={values.git}
onChange={(gitValues) =>
setValues((values) => ({
...values,
git: {
...values.git,
...gitValues,
},
}))
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
/>
<FormSection title="Advanced configurations">
<RelativePathFieldset
values={values.relativePath}
gitModel={values.git}
onChange={(relativePath) =>
setValues((values) => ({
...values,
relativePath: {
...values.relativePath,
...relativePath,
},
}))
}
/>
</FormSection>
</>
)}
</>
);
function handleChange(newValues: Partial<DockerFormValues>) {
setValues((values) => ({
...values,
...newValues,
}));
}
}

View file

@ -0,0 +1,139 @@
import { Form, useFormikContext } from 'formik';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { TextTip } from '@@/Tip/TextTip';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { FormActions } from '@@/form-components/FormActions';
import { EdgeGroupsSelector } from '../components/EdgeGroupsSelector';
import { EdgeStackDeploymentTypeSelector } from '../components/EdgeStackDeploymentTypeSelector';
import { StaggerFieldset } from '../components/StaggerFieldset';
import { PrivateRegistryFieldsetWrapper } from '../ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper';
import { useValidateEnvironmentTypes } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
import { DeploymentType } from '../types';
import { DockerComposeForm } from './DockerComposeForm';
import { KubeFormValues, KubeManifestForm } from './KubeManifestForm';
import { NameField } from './NameField';
import { WebhookSwitch } from './WebhookSwitch';
import { FormValues } from './types';
import { DeploymentOptions } from './DeploymentOptions';
export function InnerForm({
webhookId,
isLoading,
onChangeTemplate,
}: {
webhookId: string;
isLoading: boolean;
onChangeTemplate: ({
type,
id,
}: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
}) => void;
}) {
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
useFormikContext<FormValues>();
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
return (
<Form className="form-horizontal">
<NameField
onChange={(value) => setFieldValue('name', value)}
value={values.name}
errors={errors.name}
/>
<EdgeGroupsSelector
value={values.groupIds}
onChange={(value) => setFieldValue('groupIds', value)}
error={errors.groupIds}
/>
{hasKubeEndpoint && hasDockerEndpoint && (
<TextTip>
There are no available deployment types when there is more than one
type of environment in your edge group selection (e.g. Kubernetes and
Docker environments). Please select edge groups that have environments
of the same type.
</TextTip>
)}
<EdgeStackDeploymentTypeSelector
value={values.deploymentType}
hasDockerEndpoint={hasDockerEndpoint}
hasKubeEndpoint={hasKubeEndpoint}
onChange={(value) => setFieldValue('deploymentType', value)}
/>
{values.deploymentType === DeploymentType.Compose && (
<DockerComposeForm
webhookId={webhookId}
onChangeTemplate={onChangeTemplate}
/>
)}
{values.deploymentType === DeploymentType.Kubernetes && (
<KubeManifestForm
values={values as KubeFormValues}
webhookId={webhookId}
errors={errors}
setValues={(kubeValues) =>
setValues((values) => ({
...values,
...applySetStateAction(kubeValues, values as KubeFormValues),
}))
}
/>
)}
{values.method !== 'repository' && (
<WebhookSwitch
onChange={(value) => setFieldValue('enableWebhook', value)}
value={values.enableWebhook}
/>
)}
{values.deploymentType === DeploymentType.Compose && (
<EnvironmentVariablesPanel
values={values.envVars}
onChange={(value) => setFieldValue('envVars', value)}
/>
)}
<PrivateRegistryFieldsetWrapper
onChange={(value) => setFieldValue('privateRegistryId', value)}
value={values.privateRegistryId}
values={{ fileContent: values.fileContent, file: values.file }}
error={errors.privateRegistryId}
onFieldError={(message) => setFieldError('privateRegistryId', message)}
isGit={values.method === 'repository'}
/>
{values.deploymentType === DeploymentType.Compose && (
<DeploymentOptions values={values} setFieldValue={setFieldValue} />
)}
<StaggerFieldset
isEdit={false}
values={values.staggerConfig}
onChange={(value) => setFieldValue('staggerConfig', value)}
/>
<FormActions
data-cy="edgeStackCreate-createStackButton"
submitLabel="Deploy the stack"
loadingText="Deployment in progress..."
isValid={isValid}
isLoading={isLoading}
/>
</Form>
);
}

View file

@ -0,0 +1,144 @@
import { SetStateAction } from 'react';
import { FormikErrors } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { BoxSelector } from '@@/BoxSelector';
import { WebEditorForm } from '@@/WebEditorForm';
import { FileUploadForm } from '@@/form-components/FileUpload';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormSection } from '@@/form-components/FormSection';
import {
editor,
git,
upload,
} from '@@/BoxSelector/common-options/build-methods';
const buildMethods = [editor, upload, git] as const;
export interface KubeFormValues {
method: 'editor' | 'upload' | 'repository' | 'template';
useManifestNamespaces: boolean;
fileContent: string;
file?: File;
git: GitFormModel;
}
export function KubeManifestForm({
errors,
values,
setValues,
webhookId,
}: {
errors?: FormikErrors<KubeFormValues>;
values: KubeFormValues;
setValues: (values: SetStateAction<KubeFormValues>) => void;
webhookId: string;
}) {
const { method } = values;
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Use namespace(s) specified from manifest"
tooltip="If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment"
checked={values.useManifestNamespaces}
onChange={(value) =>
handleChange({
useManifestNamespaces: value,
})
}
data-cy="use-manifest-namespaces-switch"
/>
</div>
</div>
<FormSection title="Build Method">
<BoxSelector
options={buildMethods}
onChange={(value) => handleChange({ method: value })}
value={method}
radioName="method"
slim
/>
</FormSection>
{method === editor.value && (
<WebEditorForm
id="stack-creation-editor"
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
yaml
placeholder="Define or paste the content of your manifest file here"
error={errors?.fileContent}
data-cy="stack-creation-editor"
>
<KubeDeployDescription />
</WebEditorForm>
)}
{method === upload.value && (
<FileUploadForm
value={values.file}
onChange={(file) => handleChange({ file })}
required
description="You can upload a Manifest file from your computer."
data-cy="stack-creation-file-upload"
>
<KubeDeployDescription />
</FileUploadForm>
)}
{method === git.value && (
<GitForm
errors={errors?.git}
value={values.git}
onChange={(gitValues) =>
setValues((values) => ({
...values,
git: {
...values.git,
...gitValues,
},
}))
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
/>
)}
</>
);
function handleChange(newValues: Partial<KubeFormValues>) {
setValues((values) => ({
...values,
...newValues,
}));
}
}
function KubeDeployDescription() {
return (
<>
<div>
Templates allow deploying any kind of Kubernetes resource (Deployment,
Secret, ConfigMap...)
</div>
<div>
You can get more information about Kubernetes file format in the
<a
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</div>
</>
);
}

View file

@ -1,12 +1,17 @@
import { FormikErrors } from 'formik';
import { SchemaOf, string } from 'yup';
import { useMemo } from 'react';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { EdgeStack } from '../types';
import { useEdgeStacks } from '../queries/useEdgeStacks';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
import { EdgeGroup } from '../../edge-groups/types';
export function NameField({
onChange,
@ -24,7 +29,7 @@ export function NameField({
onChange={(e) => onChange(e.target.value)}
value={value}
required
data-cy="edge-stack-create-name-input"
data-cy="edgeStackCreate-nameInput"
/>
</FormControl>
);
@ -49,3 +54,23 @@ export function nameValidation(
return schema;
}
export function useNameValidation() {
const edgeStacksQuery = useEdgeStacks();
const edgeGroupsQuery = useEdgeGroups({
select: (groups) =>
Object.fromEntries(groups.map((g) => [g.Id, g.EndpointTypes])),
});
const edgeGroupsType = edgeGroupsQuery.data;
return useMemo(
() => (groupIds: Array<EdgeGroup['Id']>) =>
nameValidation(
edgeStacksQuery.data || [],
groupIds
.flatMap((g) => edgeGroupsType?.[g])
?.includes(EnvironmentType.EdgeAgentOnDocker)
),
[edgeGroupsType, edgeStacksQuery.data]
);
}

View file

@ -1,13 +1,17 @@
import { render, screen } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { EnvVarType } from '@/react/portainer/templates/app-templates/view-model';
import {
EnvVarType,
TemplateViewModel,
} from '@/react/portainer/templates/app-templates/view-model';
AppTemplate,
TemplateType,
} from '@/react/portainer/templates/app-templates/types';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { AppTemplateFieldset } from './AppTemplateFieldset';
test('renders AppTemplateFieldset component', () => {
test('renders AppTemplateFieldset component', async () => {
const testedEnv = {
name: 'VAR2',
label: 'Variable 2',
@ -27,27 +31,44 @@ test('renders AppTemplateFieldset component', () => {
testedEnv,
];
const template = {
Note: 'This is a template note',
Env: env,
} as TemplateViewModel;
id: 1,
note: 'This is a template note',
env,
type: TemplateType.ComposeStack,
categories: ['edge'],
title: 'Template title',
description: 'Template description',
administrator_only: false,
image: 'template-image',
repository: {
url: '',
stackfile: '',
},
} satisfies AppTemplate;
const values: Record<string, string> = {
VAR1: 'value1',
VAR2: 'value2',
};
const onChange = vi.fn();
render(
<AppTemplateFieldset
template={template}
values={values}
onChange={onChange}
/>
server.use(
http.get('/api/templates', () =>
HttpResponse.json({ version: '3', templates: [template] })
),
http.get('/api/registries', () => HttpResponse.json([]))
);
const templateNoteElement = screen.getByText('This is a template note');
expect(templateNoteElement).toBeInTheDocument();
const onChange = vi.fn();
const Wrapped = withTestQueryProvider(AppTemplateFieldset);
render(
<Wrapped templateId={template.id} values={values} onChange={onChange} />
);
screen.debug();
await expect(
screen.findByText('This is a template note')
).resolves.toBeInTheDocument();
const envVarsFieldsetElement = screen.getByLabelText(testedEnv.label, {
exact: false,

View file

@ -1,23 +1,31 @@
import { FormikErrors } from 'formik';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
import {
EnvVarsFieldset,
EnvVarsValue,
} from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
export function AppTemplateFieldset({
template,
templateId,
values,
onChange,
errors,
}: {
template: TemplateViewModel;
templateId: TemplateViewModel['Id'];
values: EnvVarsValue;
onChange: (value: EnvVarsValue) => void;
errors?: FormikErrors<EnvVarsValue>;
}) {
const templateQuery = useAppTemplate(templateId);
if (!templateQuery.data) {
return null;
}
const template = templateQuery.data;
return (
<>
<TemplateNote note={template.Note} />

View file

@ -1,6 +1,7 @@
import { CustomTemplatesVariablesField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateNote } from '@/react/portainer/templates/components/TemplateNote';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { ArrayError } from '@@/form-components/InputList/InputList';
@ -10,13 +11,21 @@ export function CustomTemplateFieldset({
errors,
onChange,
values,
template,
templateId,
}: {
values: Values['variables'];
onChange: (values: Values['variables']) => void;
errors: ArrayError<Values['variables']> | undefined;
template: CustomTemplate;
templateId: CustomTemplate['Id'];
}) {
const templateQuery = useCustomTemplate(templateId);
if (!templateQuery.data) {
return null;
}
const template = templateQuery.data;
return (
<>
<TemplateNote note={template.Note} />

View file

@ -1,49 +1,38 @@
import { SetStateAction, useEffect, useState } from 'react';
import { SetStateAction } from 'react';
import { FormikErrors } from 'formik';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { getDefaultValues as getAppVariablesDefaultValues } from '../../../../portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateSelector } from './TemplateSelector';
import { SelectedTemplateValue, Values } from './types';
import { Values } from './types';
import { CustomTemplateFieldset } from './CustomTemplateFieldset';
import { AppTemplateFieldset } from './AppTemplateFieldset';
export function TemplateFieldset({
values: initialValues,
setValues: setInitialValues,
values,
setValues,
errors,
}: {
errors?: FormikErrors<Values>;
values: Values;
setValues: (values: SetStateAction<Values>) => void;
}) {
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
useEffect(() => {
if (
initialValues.type !== values.type ||
initialValues.template?.Id !== values.template?.Id
) {
setControlledValues(initialValues);
}
}, [initialValues, values]);
return (
<>
<TemplateSelector
error={
typeof errors?.template === 'string' ? errors?.template : undefined
}
error={errors?.templateId}
value={values}
onChange={handleChangeTemplate}
/>
{values.template && (
{values.templateId && (
<>
{values.type === 'custom' && (
<CustomTemplateFieldset
template={values.template}
templateId={values.templateId}
values={values.variables}
onChange={(variables) =>
setValues((values) => ({ ...values, variables }))
@ -54,7 +43,7 @@ export function TemplateFieldset({
{values.type === 'app' && (
<AppTemplateFieldset
template={values.template}
templateId={values.templateId}
values={values.envVars}
onChange={(envVars) =>
setValues((values) => ({ ...values, envVars }))
@ -67,36 +56,36 @@ export function TemplateFieldset({
</>
);
function setValues(values: SetStateAction<Values>) {
setControlledValues(values);
setInitialValues(values);
}
function handleChangeTemplate(value?: SelectedTemplateValue) {
function handleChangeTemplate(
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
): void {
setValues(() => {
if (!value || !value.type || !value.template) {
if (!template || !type) {
return {
type: undefined,
template: undefined,
templateId: undefined,
variables: [],
envVars: {},
};
}
if (value.type === 'app') {
if (type === 'app') {
return {
template: value.template,
type: value.type,
templateId: template.Id,
type,
variables: [],
envVars: getAppVariablesDefaultValues(value.template.Env || []),
envVars: getAppVariablesDefaultValues(
(template as TemplateViewModel).Env || []
),
};
}
return {
template: value.template,
type: value.type,
templateId: template.Id,
type,
variables: getVariablesFieldDefaultValues(
value.template.Variables || []
(template as CustomTemplate).Variables || []
),
envVars: {},
};
@ -106,10 +95,9 @@ export function TemplateFieldset({
export function getInitialTemplateValues(): Values {
return {
template: undefined,
templateId: undefined,
type: undefined,
variables: [],
file: '',
envVars: {},
};
}

View file

@ -7,8 +7,8 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
import { server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { SelectedTemplateValue } from './types';
import { TemplateSelector } from './TemplateSelector';
test('renders TemplateSelector component', async () => {
@ -109,7 +109,10 @@ function renderComponent({
customTemplates = [],
error,
}: {
onChange?: (value: SelectedTemplateValue) => void;
onChange?: (
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
) => void;
appTemplates?: Array<Partial<AppTemplate>>;
customTemplates?: Array<Partial<CustomTemplate>>;
error?: string;
@ -128,7 +131,7 @@ function renderComponent({
render(
<Wrapped
value={{ template: undefined, type: undefined }}
value={{ templateId: undefined, type: undefined }}
onChange={onChange}
error={error}
/>

View file

@ -4,6 +4,8 @@ import { GroupBase } from 'react-select';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { useAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { FormControl } from '@@/form-components/FormControl';
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
@ -16,10 +18,13 @@ export function TemplateSelector({
error,
}: {
value: SelectedTemplateValue;
onChange: (value: SelectedTemplateValue) => void;
onChange: (
template: TemplateViewModel | CustomTemplate | undefined,
type: 'app' | 'custom' | undefined
) => void;
error?: string;
}) {
const { getTemplate, options } = useOptions();
const { options, getTemplate } = useOptions();
return (
<FormControl label="Template" inputId="template_selector" errors={error}>
@ -28,26 +33,20 @@ export function TemplateSelector({
formatGroupLabel={GroupLabel}
placeholder="Select an Edge stack template"
value={{
label: value.template?.Title,
id: value.template?.Id,
templateId: value.templateId,
type: value.type,
}}
onChange={(value) => {
if (!value) {
onChange({
template: undefined,
type: undefined,
});
onChange(undefined, undefined);
return;
}
const { id, type } = value;
if (!id || type === undefined) {
const { templateId, type } = value;
if (!templateId || type === undefined) {
return;
}
const template = getTemplate({ id, type });
onChange({ template, type } as SelectedTemplateValue);
onChange(getTemplate({ type, id: templateId }), type);
}}
options={options}
data-cy="edge-stacks-create-template-selector"
@ -80,7 +79,8 @@ function useOptions() {
options:
appTemplatesQuery.data?.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
templateId: template.Id,
type: 'app' as 'app' | 'custom',
})) || [],
},
@ -90,14 +90,16 @@ function useOptions() {
customTemplatesQuery.data && customTemplatesQuery.data.length > 0
? customTemplatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
id: template.Id,
templateId: template.Id,
type: 'custom' as 'app' | 'custom',
}))
: [
{
label: 'No edge custom templates available',
id: 0,
type: 'custom' as 'app' | 'custom',
templateId: undefined,
type: undefined,
},
],
},

View file

@ -1,14 +1,11 @@
import { VariablesFieldValue } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
export type SelectedTemplateValue =
| { template: CustomTemplate; type: 'custom' }
| { template: TemplateViewModel; type: 'app' }
| { template: undefined; type: undefined };
| { templateId: number; type: 'custom' }
| { templateId: number; type: 'app' }
| { templateId: undefined; type: undefined };
export type Values = {
file?: string;
variables: VariablesFieldValue;
envVars: Record<string, string>;
} & SelectedTemplateValue;

View file

@ -1,27 +1,33 @@
import { mixed, object, SchemaOf, string } from 'yup';
import { mixed, number, object, SchemaOf } from 'yup';
import { variablesFieldValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { envVarsFieldsetValidation } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { TemplateEnv } from '@/react/portainer/templates/app-templates/types';
function validation({
import { Values } from './types';
export function templateFieldsetValidation({
customVariablesDefinitions,
envVarDefinitions,
}: {
customVariablesDefinitions: VariableDefinition[];
envVarDefinitions: Array<TemplateEnv>;
}) {
}): SchemaOf<Values> {
return object({
type: string().oneOf(['custom', 'app']).required(),
type: mixed<'app' | 'custom'>().oneOf(['custom', 'app']).optional(),
envVars: envVarsFieldsetValidation(envVarDefinitions)
.optional()
.when('type', {
is: 'app',
then: (schema: SchemaOf<unknown, never>) => schema.required(),
}),
file: mixed().optional(),
template: object().optional().default(null),
templateId: mixed()
.optional()
.when('type', {
is: true,
then: () => number().required(),
}),
variables: variablesFieldValidation(customVariablesDefinitions)
.optional()
.when('type', {
@ -30,5 +36,3 @@ function validation({
}),
});
}
export { validation as templateFieldsetValidation };

View file

@ -0,0 +1,32 @@
import { TextTip } from '@@/Tip/TextTip';
import { SwitchField } from '@@/form-components/SwitchField';
export function WebhookSwitch({
value,
onChange,
}: {
value: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div>
<div className="form-section-title"> Webhooks </div>
<SwitchField
label="Create an Edge stack webhook"
checked={value}
onChange={onChange}
tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack."
labelClass="col-sm-3 col-lg-2"
data-cy="webhook-switch"
/>
{value && (
<TextTip>
Sending environment variables to the webhook is updating the stack
with the new values. New variables names will be added to the stack
and existing variables will be updated.
</TextTip>
)}
</div>
);
}

View file

@ -0,0 +1,37 @@
import { RegistryId } from '@/react/portainer/registries/types/registry';
import {
GitFormModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { EdgeGroup } from '../../edge-groups/types';
import { DeploymentType, StaggerConfig } from '../types';
import { KubeFormValues } from './KubeManifestForm';
import { Values as TemplateFieldsetValues } from './TemplateFieldset/types';
export type Method = 'editor' | 'upload' | 'repository' | 'template';
export interface DockerFormValues {
method: Method;
fileContent: string;
file?: File;
templateValues: TemplateFieldsetValues;
git: GitFormModel;
relativePath: RelativePathModel;
}
export interface FormValues extends KubeFormValues, DockerFormValues {
method: Method;
name: string;
groupIds: Array<EdgeGroup['Id']>;
deploymentType: DeploymentType;
envVars: EnvVarValues;
privateRegistryId: RegistryId;
prePullImage: boolean;
retryDeploy: boolean;
enableWebhook: boolean;
staggerConfig: StaggerConfig;
}

View file

@ -0,0 +1,169 @@
import { useRouter } from '@uirouter/react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
BasePayload,
CreateEdgeStackPayload,
useCreateEdgeStack,
} from '../queries/useCreateEdgeStack/useCreateEdgeStack';
import { DeploymentType } from '../types';
import { FormValues, Method } from './types';
export function useCreate({
webhookId,
template,
templateType,
}: {
webhookId: string;
template: TemplateViewModel | CustomTemplate | undefined;
templateType: 'app' | 'custom' | undefined;
}) {
const router = useRouter();
const mutation = useCreateEdgeStack();
const { user } = useCurrentUser();
const { trackEvent } = useAnalytics();
return {
isLoading: mutation.isLoading,
onSubmit: handleSubmit,
};
function handleSubmit(values: FormValues) {
const method = getMethod(
values.method,
getIsGitTemplate(template, templateType)
);
trackEvent('edge-stack-creation', {
category: 'edge',
metadata: buildAnalyticsMetadata(
values.method,
values.deploymentType,
template?.Title
),
});
mutation.mutate(getPayload(method, values), {
onSuccess: () => {
router.stateService.go('^');
},
});
function getPayload(
method: 'string' | 'file' | 'git',
values: FormValues
): CreateEdgeStackPayload {
switch (method) {
case 'file':
if (!values.file) {
throw new Error('File is required');
}
return {
method: 'file',
payload: {
...getBasePayload(values),
file: values.file,
webhook: values.enableWebhook ? webhookId : undefined,
},
};
case 'string':
return {
method: 'string',
payload: {
...getBasePayload(values),
fileContent: values.fileContent,
webhook: values.enableWebhook ? webhookId : undefined,
},
};
case 'git':
return {
method: 'git',
payload: {
...getBasePayload(values),
git: values.git,
relativePathSettings: values.relativePath,
},
};
default:
throw new Error(`Unknown method: ${method}`);
}
}
function getBasePayload(values: FormValues): BasePayload {
return {
userId: user.Id,
deploymentType: values.deploymentType,
edgeGroups: values.groupIds,
name: values.name,
envVars: values.envVars,
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
prePullImage: values.prePullImage,
retryDeploy: values.retryDeploy,
staggerConfig: values.staggerConfig,
useManifestNamespaces: values.useManifestNamespaces,
};
}
}
function buildAnalyticsMetadata(
method: Method,
type: DeploymentType,
templateTitle: string | undefined
) {
return {
type: methodLabel(method),
format: type === DeploymentType.Compose ? 'compose' : 'kubernetes',
templateName: templateTitle,
};
function methodLabel(method: Method) {
switch (method) {
case 'repository':
return 'git';
case 'upload':
return 'file-upload';
case 'template':
return 'template';
case 'editor':
default:
return 'web-editor';
}
}
}
}
function getMethod(
method: 'template' | 'repository' | 'editor' | 'upload',
isGitTemplate: boolean
): 'string' | 'file' | 'git' {
switch (method) {
case 'upload':
return 'file';
case 'repository':
return 'git';
case 'template':
if (isGitTemplate) {
return 'git';
}
return 'string';
case 'editor':
default:
return 'string';
}
}
function getIsGitTemplate(
template: TemplateViewModel | CustomTemplate | undefined,
templateType: 'app' | 'custom' | undefined
) {
if (templateType === 'app') {
return false;
}
return !!template && !!(template as CustomTemplate).GitConfig;
}

View file

@ -0,0 +1,94 @@
import { SetStateAction, useEffect, useState } from 'react';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { StackType } from '@/react/common/stacks/types';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { DeploymentType } from '../types';
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
import { DockerFormValues, FormValues } from './types';
export function useRenderTemplate(
templateValues: DockerFormValues['templateValues'],
setValues: (values: SetStateAction<DockerFormValues>) => void
) {
const templateQuery = useCustomTemplate(templateValues.templateId);
const template = templateQuery.data;
const templateFileQuery = useCustomTemplateFile(
templateValues.templateId,
!!template?.GitConfig
);
const [renderedFile, setRenderedFile] = useState<string>('');
useEffect(() => {
if (templateFileQuery.data) {
const newFile = renderTemplate(
templateFileQuery.data,
templateValues.variables,
template?.Variables || []
);
if (newFile !== renderedFile) {
setRenderedFile(newFile);
setValues((values) => ({
...values,
fileContent: newFile,
}));
}
}
}, [
renderedFile,
setValues,
template,
templateFileQuery.data,
templateValues.variables,
]);
const [currentTemplateId, setCurrentTemplateId] = useState<
number | undefined
>(templateValues.templateId);
useEffect(() => {
if (template?.Id !== currentTemplateId) {
setCurrentTemplateId(template?.Id);
setValues((values) => ({
...values,
...getValuesFromTemplate(template),
}));
}
}, [currentTemplateId, setValues, template]);
return template;
}
function getValuesFromTemplate(
template: CustomTemplate | undefined
): Partial<FormValues> {
if (!template) {
return {};
}
return {
deploymentType:
template.Type === StackType.Kubernetes
? DeploymentType.Kubernetes
: DeploymentType.Compose,
git: toGitFormModel(template.GitConfig),
...(template.EdgeSettings
? {
prePullImage: template.EdgeSettings.PrePullImage || false,
retryDeploy: template.EdgeSettings.RetryDeploy || false,
privateRegistryId: template.EdgeSettings.PrivateRegistryId,
staggerConfig:
template.EdgeSettings.StaggerConfig || getDefaultStaggerConfig(),
...template.EdgeSettings.RelativePathSettings,
}
: {}),
};
}

View file

@ -0,0 +1,37 @@
import { useParamState } from '@/react/hooks/useParamState';
export function useTemplateParams() {
const [id, setTemplateId] = useParamState('templateId', (param) => {
if (!param) {
return undefined;
}
const templateId = parseInt(param, 10);
if (Number.isNaN(templateId)) {
return undefined;
}
return templateId;
});
const [type, setTemplateType] = useParamState('templateType', (param) => {
if (param === 'app' || param === 'custom') {
return param;
}
return undefined;
});
return [{ id, type }, handleChange] as const;
function handleChange({
id,
type,
}: {
id: number | undefined;
type: 'app' | 'custom' | undefined;
}) {
setTemplateId(id);
setTemplateType(type);
}
}