mirror of https://github.com/portainer/portainer
fix(app templates): load app template for deployment [BE-11382] (#141)
parent
20e3d3a15b
commit
c0c7144539
|
@ -15,6 +15,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
|
|||
'radioName',
|
||||
'slim',
|
||||
'hiddenSpacingCount',
|
||||
'error',
|
||||
]);
|
||||
|
||||
export const boxSelectorModule = angular
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import styles from './BoxSelector.module.css';
|
||||
import { BoxSelectorItem } from './BoxSelectorItem';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
|
@ -21,6 +23,7 @@ export type Props<T extends Value> = Union<T> & {
|
|||
options: ReadonlyArray<BoxSelectorOption<T>> | Array<BoxSelectorOption<T>>;
|
||||
slim?: boolean;
|
||||
hiddenSpacingCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function BoxSelector<T extends Value>({
|
||||
|
@ -28,6 +31,7 @@ export function BoxSelector<T extends Value>({
|
|||
options,
|
||||
slim = false,
|
||||
hiddenSpacingCount,
|
||||
error,
|
||||
...props
|
||||
}: Props<T>) {
|
||||
return (
|
||||
|
@ -54,6 +58,7 @@ export function BoxSelector<T extends Value>({
|
|||
<div key={index} className="flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { EdgeGroupListItemResponse } from '../../queries/useEdgeGroups';
|
||||
|
||||
|
@ -32,7 +33,9 @@ function NameCell({
|
|||
{name}
|
||||
</Link>
|
||||
{(item.HasEdgeJob || item.HasEdgeStack) && (
|
||||
<span className="label label-info image-tag space-left">in use</span>
|
||||
<Badge type="info" className="ml-1">
|
||||
in use
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,186 @@
|
|||
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);
|
||||
});
|
||||
});
|
|
@ -18,12 +18,15 @@ import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFie
|
|||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/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 { createHasEnvironmentTypeFunction } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
|
||||
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { FormValues, Method } from './types';
|
||||
import { templateFieldsetValidation } from './TemplateFieldset/validation';
|
||||
|
@ -39,6 +42,8 @@ export function useValidation({
|
|||
const { user } = useCurrentUser();
|
||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||
const nameValidation = useNameValidation();
|
||||
const edgeGroupsQuery = useEdgeGroups();
|
||||
const edgeGroups = edgeGroupsQuery.data;
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
|
@ -53,7 +58,47 @@ export function useValidation({
|
|||
.min(1, 'At least one Edge group is required'),
|
||||
deploymentType: mixed<DeploymentType>()
|
||||
.oneOf([DeploymentType.Compose, DeploymentType.Kubernetes])
|
||||
.required(),
|
||||
.required()
|
||||
.test(
|
||||
'kubernetes-deployment-type-validation',
|
||||
'Kubernetes deployment type is not compatible with the selected edge group(s), which contain Docker environments',
|
||||
(value) => {
|
||||
if (value !== DeploymentType.Kubernetes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasType = createHasEnvironmentTypeFunction(
|
||||
values.groupIds,
|
||||
edgeGroups
|
||||
);
|
||||
|
||||
const hasDockerEndpoint = hasType(
|
||||
EnvironmentType.EdgeAgentOnDocker
|
||||
);
|
||||
|
||||
return !hasDockerEndpoint;
|
||||
}
|
||||
)
|
||||
.test(
|
||||
'compose-deployment-type-validation',
|
||||
'Compose deployment type is not compatible with the selected edge group(s), which contain Kubernetes environments',
|
||||
(value) => {
|
||||
if (value !== DeploymentType.Compose) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasType = createHasEnvironmentTypeFunction(
|
||||
values.groupIds,
|
||||
edgeGroups
|
||||
);
|
||||
|
||||
const hasKubeEndpoint = hasType(
|
||||
EnvironmentType.EdgeAgentOnKubernetes
|
||||
);
|
||||
|
||||
return !hasKubeEndpoint;
|
||||
}
|
||||
),
|
||||
envVars: envVarValidation(),
|
||||
privateRegistryId: number().default(0),
|
||||
prePullImage: boolean().default(false),
|
||||
|
@ -92,6 +137,12 @@ export function useValidation({
|
|||
useManifestNamespaces: boolean().default(false),
|
||||
})
|
||||
),
|
||||
[appTemplate?.Env, customTemplate, gitCredentialsQuery.data, nameValidation]
|
||||
[
|
||||
appTemplate?.Env,
|
||||
customTemplate,
|
||||
edgeGroups,
|
||||
gitCredentialsQuery.data,
|
||||
nameValidation,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@ export function CreateView() {
|
|||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create Edge stack"
|
||||
title="Create Edge Stack"
|
||||
breadcrumbs={[
|
||||
{ label: 'Edge Stacks', link: 'edge.stacks' },
|
||||
'Create Edge stack',
|
||||
'Create Edge Stack',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
|
|
@ -16,9 +16,10 @@ import {
|
|||
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||
|
||||
import { TemplateFieldset } from './TemplateFieldset/TemplateFieldset';
|
||||
import { useRenderTemplate } from './useRenderTemplate';
|
||||
import { useRenderCustomTemplate } from './useRenderCustomTemplate';
|
||||
import { DockerFormValues } from './types';
|
||||
import { DockerContentField } from './DockerContentField';
|
||||
import { useRenderAppTemplate } from './useRenderAppTemplate';
|
||||
|
||||
const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||
|
||||
|
@ -38,7 +39,14 @@ export function DockerComposeForm({
|
|||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||
const { method } = values;
|
||||
|
||||
const template = useRenderTemplate(values.templateValues, setValues);
|
||||
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 (
|
||||
<>
|
||||
|
@ -73,15 +81,17 @@ export function DockerComposeForm({
|
|||
})
|
||||
}
|
||||
errors={errors?.templateValues}
|
||||
isLoadingValues={isTemplateLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(method === editor.value ||
|
||||
(method === edgeStackTemplate.value && template)) && (
|
||||
{(method === editor.value || isTemplate) && !isTemplateLoading && (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
readonly={method === edgeStackTemplate.value && !!template?.GitConfig}
|
||||
readonly={
|
||||
method === edgeStackTemplate.value && !!customTemplate?.GitConfig
|
||||
}
|
||||
error={errors?.fileContent}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -3,7 +3,6 @@ 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';
|
||||
|
||||
|
@ -11,7 +10,7 @@ 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 { useEdgeGroupHasType } from '../ItemView/EditEdgeStackForm/useEdgeGroupHasType';
|
||||
import { DeploymentType } from '../types';
|
||||
|
||||
import { DockerComposeForm } from './DockerComposeForm';
|
||||
|
@ -38,13 +37,20 @@ export function InnerForm({
|
|||
}) {
|
||||
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
|
||||
useFormikContext<FormValues>();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||
const { hasType } = useEdgeGroupHasType(values.groupIds);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
const hasMultipleTypes = hasKubeEndpoint && hasDockerEndpoint;
|
||||
const multipleTypesError = hasMultipleTypes
|
||||
? `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.`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<Form className="form-horizontal" role="form">
|
||||
<NameField
|
||||
onChange={(value) => setFieldValue('name', value)}
|
||||
value={values.name}
|
||||
|
@ -54,23 +60,15 @@ export function InnerForm({
|
|||
<EdgeGroupsSelector
|
||||
value={values.groupIds}
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
error={errors.groupIds}
|
||||
error={errors.groupIds || multipleTypesError}
|
||||
/>
|
||||
|
||||
{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)}
|
||||
error={errors.deploymentType}
|
||||
/>
|
||||
|
||||
{values.deploymentType === DeploymentType.Compose && (
|
||||
|
|
|
@ -16,10 +16,12 @@ export function TemplateFieldset({
|
|||
values,
|
||||
setValues,
|
||||
errors,
|
||||
isLoadingValues,
|
||||
}: {
|
||||
errors?: FormikErrors<Values>;
|
||||
values: Values;
|
||||
setValues: (values: SetStateAction<Values>) => void;
|
||||
isLoadingValues?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
|
@ -27,8 +29,9 @@ export function TemplateFieldset({
|
|||
error={errors?.templateId}
|
||||
value={values}
|
||||
onChange={handleChangeTemplate}
|
||||
isLoadingValues={isLoadingValues}
|
||||
/>
|
||||
{values.templateId && (
|
||||
{values.templateId && !isLoadingValues && (
|
||||
<>
|
||||
{values.type === 'custom' && (
|
||||
<CustomTemplateFieldset
|
||||
|
|
|
@ -9,6 +9,7 @@ import { CustomTemplate } from '@/react/portainer/templates/custom-templates/typ
|
|||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select as ReactSelect } from '@@/form-components/ReactSelect';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
||||
import { SelectedTemplateValue } from './types';
|
||||
|
||||
|
@ -16,6 +17,7 @@ export function TemplateSelector({
|
|||
value,
|
||||
onChange,
|
||||
error,
|
||||
isLoadingValues,
|
||||
}: {
|
||||
value: SelectedTemplateValue;
|
||||
onChange: (
|
||||
|
@ -23,6 +25,7 @@ export function TemplateSelector({
|
|||
type: 'app' | 'custom' | undefined
|
||||
) => void;
|
||||
error?: string;
|
||||
isLoadingValues?: boolean;
|
||||
}) {
|
||||
const { options, getTemplate, selectedValue } = useOptions(value);
|
||||
|
||||
|
@ -48,6 +51,9 @@ export function TemplateSelector({
|
|||
}}
|
||||
data-cy="edge-stacks-create-template-selector"
|
||||
/>
|
||||
{isLoadingValues && (
|
||||
<InlineLoader>Loading template values...</InlineLoader>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -102,12 +102,18 @@ export function useCreate({
|
|||
}
|
||||
|
||||
function getBasePayload(values: FormValues): BasePayload {
|
||||
const templateEnvVarsAsPairs = Object.entries(
|
||||
values.templateValues.envVars
|
||||
).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
return {
|
||||
userId: user.Id,
|
||||
deploymentType: values.deploymentType,
|
||||
edgeGroups: values.groupIds,
|
||||
name: values.name,
|
||||
envVars: values.envVars,
|
||||
envVars: [...values.envVars, ...templateEnvVarsAsPairs],
|
||||
registries: values.privateRegistryId ? [values.privateRegistryId] : [],
|
||||
prePullImage: values.prePullImage,
|
||||
retryDeploy: values.retryDeploy,
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
import { SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
|
||||
import { useAppTemplateFile } from '@/react/portainer/templates/app-templates/queries/useAppTemplateFile';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
|
||||
import { DeploymentType } from '../types';
|
||||
import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
||||
|
||||
import { DockerFormValues, FormValues } from './types';
|
||||
|
||||
/**
|
||||
* useRenderAppTemplate fetches the app template (file and data) and returns it
|
||||
* as a TemplateViewModel.
|
||||
*
|
||||
* It also renders the template file and updates the form values.
|
||||
*/
|
||||
export function useRenderAppTemplate(
|
||||
templateValues: DockerFormValues['templateValues'],
|
||||
setValues: (values: SetStateAction<DockerFormValues>) => void
|
||||
) {
|
||||
const templateQuery = useAppTemplate(templateValues.templateId, {
|
||||
enabled: templateValues.type === 'app',
|
||||
});
|
||||
|
||||
const template = templateQuery.data;
|
||||
|
||||
const templateFileQuery = useAppTemplateFile(templateValues.templateId, {
|
||||
enabled: templateValues.type === 'app',
|
||||
});
|
||||
const [renderedFile, setRenderedFile] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (templateFileQuery.data) {
|
||||
const newFile = renderTemplate(
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
[]
|
||||
);
|
||||
|
||||
if (newFile !== renderedFile) {
|
||||
setRenderedFile(newFile);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
fileContent: newFile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
renderedFile,
|
||||
setValues,
|
||||
template,
|
||||
templateFileQuery.data,
|
||||
templateValues.variables,
|
||||
]);
|
||||
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState<
|
||||
number | undefined
|
||||
>(templateValues.templateId);
|
||||
|
||||
useEffect(() => {
|
||||
if (template?.Id !== currentTemplateId) {
|
||||
setCurrentTemplateId(template?.Id);
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...getValuesFromAppTemplate(template),
|
||||
}));
|
||||
}
|
||||
}, [currentTemplateId, setValues, template]);
|
||||
|
||||
return {
|
||||
appTemplate: template,
|
||||
isInitialLoading:
|
||||
templateQuery.isInitialLoading || templateFileQuery.isInitialLoading,
|
||||
};
|
||||
}
|
||||
|
||||
function getValuesFromAppTemplate(
|
||||
template: TemplateViewModel | undefined
|
||||
): Partial<FormValues> {
|
||||
if (!template) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
deploymentType: DeploymentType.Compose,
|
||||
...(template
|
||||
? {
|
||||
prePullImage: false,
|
||||
retryDeploy: false,
|
||||
staggerConfig: getDefaultStaggerConfig(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
|
@ -12,7 +12,7 @@ import { getDefaultStaggerConfig } from '../components/StaggerFieldset.types';
|
|||
|
||||
import { DockerFormValues, FormValues } from './types';
|
||||
|
||||
export function useRenderTemplate(
|
||||
export function useRenderCustomTemplate(
|
||||
templateValues: DockerFormValues['templateValues'],
|
||||
setValues: (values: SetStateAction<DockerFormValues>) => void
|
||||
) {
|
||||
|
@ -69,7 +69,11 @@ export function useRenderTemplate(
|
|||
}
|
||||
}, [currentTemplateId, setValues, template]);
|
||||
|
||||
return template;
|
||||
return {
|
||||
customTemplate: template,
|
||||
isInitialLoading:
|
||||
templateQuery.isInitialLoading || templateFileQuery.isInitialLoading,
|
||||
};
|
||||
}
|
||||
|
||||
function getValuesFromTemplate(
|
|
@ -42,7 +42,7 @@ import { FormError } from '@@/form-components/FormError';
|
|||
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
||||
|
||||
import { useValidateEnvironmentTypes } from '../useEdgeGroupHasType';
|
||||
import { useEdgeGroupHasType } from '../useEdgeGroupHasType';
|
||||
import { PrivateRegistryFieldset } from '../../../components/PrivateRegistryFieldset';
|
||||
|
||||
import {
|
||||
|
@ -172,7 +172,7 @@ function InnerForm({
|
|||
const { values, setFieldValue, isValid, handleSubmit, errors, dirty } =
|
||||
useFormikContext<FormValues>();
|
||||
|
||||
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||
const { hasType } = useEdgeGroupHasType(values.groupIds);
|
||||
|
||||
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||
|
|
|
@ -46,7 +46,7 @@ import { getDefaultStaggerConfig } from '../../components/StaggerFieldset.types'
|
|||
|
||||
import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper';
|
||||
import { FormValues } from './types';
|
||||
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
||||
import { useEdgeGroupHasType } from './useEdgeGroupHasType';
|
||||
import { useStaggerUpdateStatus } from './useStaggerUpdateStatus';
|
||||
import { useUpdateEdgeStackMutation } from './useUpdateEdgeStackMutation';
|
||||
import { ComposeForm } from './ComposeForm';
|
||||
|
@ -194,7 +194,7 @@ function InnerForm({
|
|||
usePreventExit(initialValues.content, values.content, !isSaved);
|
||||
|
||||
const { getCachedContent, setContentCache } = useCachedContent();
|
||||
const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
|
||||
const { hasType } = useEdgeGroupHasType(values.edgeGroups);
|
||||
const staggerUpdateStatus = useStaggerUpdateStatus(edgeStack.Id);
|
||||
const [selectedVersion, setSelectedVersion] = useState(versionOptions?.[0]);
|
||||
const selectedParallelOption =
|
||||
|
|
|
@ -5,22 +5,40 @@ import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useValidateEnvironmentTypes(groupIds: Array<EdgeGroup['Id']>) {
|
||||
export function useEdgeGroupHasType(groupIds: Array<EdgeGroup['Id']>) {
|
||||
const edgeGroupsQuery = useEdgeGroups();
|
||||
|
||||
const edgeGroups = edgeGroupsQuery.data || [];
|
||||
const edgeGroups = edgeGroupsQuery.data;
|
||||
|
||||
const modelEdgeGroups = _.compact(
|
||||
groupIds.map((id) => edgeGroups.find((e) => e.Id === id))
|
||||
const hasTypeFunction = createHasEnvironmentTypeFunction(
|
||||
groupIds,
|
||||
edgeGroups
|
||||
);
|
||||
const endpointTypes = modelEdgeGroups.flatMap((group) => group.EndpointTypes);
|
||||
|
||||
const hasType = useCallback(
|
||||
(type: EnvironmentType) => endpointTypes.includes(type),
|
||||
[endpointTypes]
|
||||
(type: EnvironmentType) => hasTypeFunction(type),
|
||||
[hasTypeFunction]
|
||||
);
|
||||
|
||||
return {
|
||||
hasType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any of the edge groups have the given type
|
||||
*/
|
||||
export function createHasEnvironmentTypeFunction(
|
||||
groupIds: EdgeGroup['Id'][],
|
||||
edgeGroups?: EdgeGroup[]
|
||||
) {
|
||||
const modelEdgeGroups = _.compact(
|
||||
groupIds.map((id) => edgeGroups?.find((e) => e.Id === id))
|
||||
);
|
||||
const endpointTypes = modelEdgeGroups.flatMap((group) => group.EndpointTypes);
|
||||
|
||||
function hasType(type: EnvironmentType) {
|
||||
return endpointTypes.includes(type);
|
||||
}
|
||||
|
||||
return hasType;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ interface Props {
|
|||
hasDockerEndpoint: boolean;
|
||||
hasKubeEndpoint: boolean;
|
||||
allowKubeToSelectCompose?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function EdgeStackDeploymentTypeSelector({
|
||||
|
@ -21,6 +22,7 @@ export function EdgeStackDeploymentTypeSelector({
|
|||
hasDockerEndpoint,
|
||||
hasKubeEndpoint,
|
||||
allowKubeToSelectCompose,
|
||||
error,
|
||||
}: Props) {
|
||||
const deploymentOptions: BoxSelectorOption<DeploymentType>[] = [
|
||||
{
|
||||
|
@ -52,6 +54,7 @@ export function EdgeStackDeploymentTypeSelector({
|
|||
value={value}
|
||||
options={deploymentOptions}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -26,11 +26,11 @@ export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
|
|||
'repository',
|
||||
'repositories'
|
||||
)}?`}
|
||||
data-cy="credentials-deleteButton"
|
||||
data-cy="helmRepository-deleteButton"
|
||||
/>
|
||||
<AddButton
|
||||
to="portainer.account.createHelmRepository"
|
||||
data-cy="credentials-addButton"
|
||||
data-cy="helmRepository-addButton"
|
||||
>
|
||||
Add Helm repository
|
||||
</AddButton>
|
||||
|
|
|
@ -6,9 +6,12 @@ import { AppTemplate } from '../types';
|
|||
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export function useFetchTemplateFile(id?: AppTemplate['id']) {
|
||||
export function useAppTemplateFile(
|
||||
id?: AppTemplate['id'],
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(['templates', id, 'file'], () => fetchFilePreview(id!), {
|
||||
enabled: !!id,
|
||||
enabled: !!id && enabled,
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,11 @@ import { TemplateViewModel } from '../view-model';
|
|||
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export type AppTemplatesResponse = {
|
||||
version: string;
|
||||
templates: Array<AppTemplate>;
|
||||
};
|
||||
|
||||
export function useAppTemplates<T = Array<TemplateViewModel>>({
|
||||
environmentId,
|
||||
select,
|
||||
|
@ -43,15 +48,10 @@ export function useAppTemplate(
|
|||
id: AppTemplate['id'] | undefined,
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
const templateListQuery = useAppTemplates({ enabled: !!id && enabled });
|
||||
|
||||
const template = templateListQuery.data?.find((t) => t.Id === id);
|
||||
|
||||
return {
|
||||
data: template,
|
||||
isLoading: templateListQuery.isInitialLoading,
|
||||
error: templateListQuery.error,
|
||||
};
|
||||
return useAppTemplates({
|
||||
enabled: !!id && enabled,
|
||||
select: (templates) => templates.find((t) => t.Id === id),
|
||||
});
|
||||
}
|
||||
|
||||
async function getTemplatesWithRegistry(
|
||||
|
@ -75,10 +75,7 @@ async function getTemplatesWithRegistry(
|
|||
|
||||
export async function getAppTemplates() {
|
||||
try {
|
||||
const { data } = await axios.get<{
|
||||
version: string;
|
||||
templates: Array<AppTemplate>;
|
||||
}>(buildUrl());
|
||||
const { data } = await axios.get<AppTemplatesResponse>(buildUrl());
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err);
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
|||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { Platform } from '../../types';
|
||||
import { useFetchTemplateFile } from '../../app-templates/queries/useFetchTemplateFile';
|
||||
import { useAppTemplateFile } from '../../app-templates/queries/useAppTemplateFile';
|
||||
import { getDefaultEdgeTemplateSettings } from '../types';
|
||||
|
||||
import { FormValues, Method } from './types';
|
||||
|
@ -31,7 +31,7 @@ export function useInitialValues({
|
|||
params: { fileContent = '' },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const fileContentQuery = useFetchTemplateFile(appTemplateId);
|
||||
const fileContentQuery = useAppTemplateFile(appTemplateId);
|
||||
if (fileContentQuery.isInitialLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { configure } from '@testing-library/react';
|
||||
|
||||
configure({ testIdAttribute: 'data-cy' });
|
|
@ -7,7 +7,7 @@ export default defineConfig({
|
|||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./app/setup-tests/setup-msw.ts', './app/setup-tests/stub-modules.ts', './app/setup-tests/setup.ts'],
|
||||
setupFiles: ['./app/setup-tests/setup-msw.ts', './app/setup-tests/stub-modules.ts', './app/setup-tests/setup.ts', './app/setup-tests/setup-rtl.ts'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
exclude: ['node_modules/', 'app/setup-tests/global-setup.js'],
|
||||
|
|
Loading…
Reference in New Issue