fix(app templates): load app template for deployment [BE-11382] (#141)

pull/11530/merge
Ali 2024-11-25 17:41:09 +13:00 committed by GitHub
parent 20e3d3a15b
commit c0c7144539
23 changed files with 453 additions and 60 deletions

View File

@ -15,6 +15,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [
'radioName',
'slim',
'hiddenSpacingCount',
'error',
]);
export const boxSelectorModule = angular

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 && (

View File

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

View File

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

View File

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

View File

@ -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(),
}
: {}),
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import { configure } from '@testing-library/react';
configure({ testIdAttribute: 'data-cy' });

View File

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