diff --git a/app/portainer/components/BoxSelector/index.ts b/app/portainer/components/BoxSelector/index.ts index 95caaa132..3e6385b98 100644 --- a/app/portainer/components/BoxSelector/index.ts +++ b/app/portainer/components/BoxSelector/index.ts @@ -15,6 +15,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [ 'radioName', 'slim', 'hiddenSpacingCount', + 'error', ]); export const boxSelectorModule = angular diff --git a/app/react/components/BoxSelector/BoxSelector.tsx b/app/react/components/BoxSelector/BoxSelector.tsx index 2a924263d..8eaab9d2d 100644 --- a/app/react/components/BoxSelector/BoxSelector.tsx +++ b/app/react/components/BoxSelector/BoxSelector.tsx @@ -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 = Union & { options: ReadonlyArray> | Array>; slim?: boolean; hiddenSpacingCount?: number; + error?: string; }; export function BoxSelector({ @@ -28,6 +31,7 @@ export function BoxSelector({ options, slim = false, hiddenSpacingCount, + error, ...props }: Props) { return ( @@ -54,6 +58,7 @@ export function BoxSelector({
))}
+ {error && {error}} ); diff --git a/app/react/edge/edge-groups/ListView/columns/name.tsx b/app/react/edge/edge-groups/ListView/columns/name.tsx index e4ce8b49f..b3819bc41 100644 --- a/app/react/edge/edge-groups/ListView/columns/name.tsx +++ b/app/react/edge/edge-groups/ListView/columns/name.tsx @@ -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} {(item.HasEdgeJob || item.HasEdgeStack) && ( - in use + + in use + )} ); diff --git a/app/react/edge/edge-stacks/CreateView/CreateForm.test.tsx b/app/react/edge/edge-stacks/CreateView/CreateForm.test.tsx new file mode 100644 index 000000000..08506c005 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/CreateForm.test.tsx @@ -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) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { templateId: 54, templateType: 'app' }, + })), +})); + +vi.mock('@uiw/react-codemirror', () => ({ + __esModule: true, + default: () =>
, +})); + +// 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(); +} + +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); + }); +}); diff --git a/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts b/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts index a6dbbe961..ae8b672ab 100644 --- a/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts +++ b/app/react/edge/edge-stacks/CreateView/CreateForm.validation.ts @@ -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() .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, + ] ); } diff --git a/app/react/edge/edge-stacks/CreateView/CreateView.tsx b/app/react/edge/edge-stacks/CreateView/CreateView.tsx index 6fd13e45d..b492dbfd4 100644 --- a/app/react/edge/edge-stacks/CreateView/CreateView.tsx +++ b/app/react/edge/edge-stacks/CreateView/CreateView.tsx @@ -6,10 +6,10 @@ export function CreateView() { return ( <> diff --git a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx index fe24a2888..9f120e755 100644 --- a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx @@ -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(); 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 && ( handleChange({ fileContent: value })} - readonly={method === edgeStackTemplate.value && !!template?.GitConfig} + readonly={ + method === edgeStackTemplate.value && !!customTemplate?.GitConfig + } error={errors?.fileContent} /> )} diff --git a/app/react/edge/edge-stacks/CreateView/InnerForm.tsx b/app/react/edge/edge-stacks/CreateView/InnerForm.tsx index f21ee45b6..995d8d8ef 100644 --- a/app/react/edge/edge-stacks/CreateView/InnerForm.tsx +++ b/app/react/edge/edge-stacks/CreateView/InnerForm.tsx @@ -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(); - 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 ( -
+ setFieldValue('name', value)} value={values.name} @@ -54,23 +60,15 @@ export function InnerForm({ setFieldValue('groupIds', value)} - error={errors.groupIds} + error={errors.groupIds || multipleTypesError} /> - {hasKubeEndpoint && hasDockerEndpoint && ( - - 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. - - )} - setFieldValue('deploymentType', value)} + error={errors.deploymentType} /> {values.deploymentType === DeploymentType.Compose && ( diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx index 8746aa8b8..f479ee729 100644 --- a/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset.tsx @@ -16,10 +16,12 @@ export function TemplateFieldset({ values, setValues, errors, + isLoadingValues, }: { errors?: FormikErrors; values: Values; setValues: (values: SetStateAction) => 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' && ( 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 && ( + Loading template values... + )} ); } diff --git a/app/react/edge/edge-stacks/CreateView/useCreate.tsx b/app/react/edge/edge-stacks/CreateView/useCreate.tsx index 9681a839c..bd122a6e2 100644 --- a/app/react/edge/edge-stacks/CreateView/useCreate.tsx +++ b/app/react/edge/edge-stacks/CreateView/useCreate.tsx @@ -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, diff --git a/app/react/edge/edge-stacks/CreateView/useRenderAppTemplate.tsx b/app/react/edge/edge-stacks/CreateView/useRenderAppTemplate.tsx new file mode 100644 index 000000000..78966d2ce --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/useRenderAppTemplate.tsx @@ -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) => 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(''); + + 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 { + if (!template) { + return {}; + } + + return { + deploymentType: DeploymentType.Compose, + ...(template + ? { + prePullImage: false, + retryDeploy: false, + staggerConfig: getDefaultStaggerConfig(), + } + : {}), + }; +} diff --git a/app/react/edge/edge-stacks/CreateView/useRenderTemplate.tsx b/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx similarity index 94% rename from app/react/edge/edge-stacks/CreateView/useRenderTemplate.tsx rename to app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx index c5fa62ce3..00946f7f7 100644 --- a/app/react/edge/edge-stacks/CreateView/useRenderTemplate.tsx +++ b/app/react/edge/edge-stacks/CreateView/useRenderCustomTemplate.tsx @@ -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) => void ) { @@ -69,7 +69,11 @@ export function useRenderTemplate( } }, [currentTemplateId, setValues, template]); - return template; + return { + customTemplate: template, + isInitialLoading: + templateQuery.isInitialLoading || templateFileQuery.isInitialLoading, + }; } function getValuesFromTemplate( diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx index 49fe364be..9535395e8 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx @@ -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(); - const { hasType } = useValidateEnvironmentTypes(values.groupIds); + const { hasType } = useEdgeGroupHasType(values.groupIds); const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes); const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker); diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx index d259e0773..d0d1e5373 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx @@ -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 = diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts index dd5910837..d0223b72c 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts @@ -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) { +export function useEdgeGroupHasType(groupIds: Array) { 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; +} diff --git a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx index 479a086a2..c25ecd773 100644 --- a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx +++ b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx @@ -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[] = [ { @@ -52,6 +54,7 @@ export function EdgeStackDeploymentTypeSelector({ value={value} options={deploymentOptions} onChange={onChange} + error={error} /> ); diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatableActions.tsx b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatableActions.tsx index 8a2408e39..08f86178c 100644 --- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatableActions.tsx +++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/HelmRepositoryDatatableActions.tsx @@ -26,11 +26,11 @@ export function HelmRepositoryDatatableActions({ selectedItems }: Props) { 'repository', 'repositories' )}?`} - data-cy="credentials-deleteButton" + data-cy="helmRepository-deleteButton" /> Add Helm repository diff --git a/app/react/portainer/templates/app-templates/queries/useFetchTemplateFile.ts b/app/react/portainer/templates/app-templates/queries/useAppTemplateFile.ts similarity index 80% rename from app/react/portainer/templates/app-templates/queries/useFetchTemplateFile.ts rename to app/react/portainer/templates/app-templates/queries/useAppTemplateFile.ts index 39d411d82..f876db6e8 100644 --- a/app/react/portainer/templates/app-templates/queries/useFetchTemplateFile.ts +++ b/app/react/portainer/templates/app-templates/queries/useAppTemplateFile.ts @@ -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, }); } diff --git a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts index f8337c4dd..4aa42f0ed 100644 --- a/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts +++ b/app/react/portainer/templates/app-templates/queries/useAppTemplates.ts @@ -12,6 +12,11 @@ import { TemplateViewModel } from '../view-model'; import { buildUrl } from './build-url'; +export type AppTemplatesResponse = { + version: string; + templates: Array; +}; + export function useAppTemplates>({ 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; - }>(buildUrl()); + const { data } = await axios.get(buildUrl()); return data; } catch (err) { throw parseAxiosError(err); diff --git a/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts index 80cdc8d7e..f0f1eca95 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts +++ b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts @@ -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; } diff --git a/app/setup-tests/setup-rtl.ts b/app/setup-tests/setup-rtl.ts new file mode 100644 index 000000000..2bdcdd1f7 --- /dev/null +++ b/app/setup-tests/setup-rtl.ts @@ -0,0 +1,3 @@ +import { configure } from '@testing-library/react'; + +configure({ testIdAttribute: 'data-cy' }); diff --git a/vitest.config.mts b/vitest.config.mts index 89caa757a..c07c0096b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -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'],