diff --git a/app/docker/__module.js b/app/docker/__module.js index 7b9e4073f..448147b62 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -442,8 +442,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ url: '/newstack', views: { 'content@': { - templateUrl: '~Portainer/views/stacks/create/createstack.html', - controller: 'CreateStackController', + component: 'createStackView', }, }, }; diff --git a/app/docker/react/views/stacks.ts b/app/docker/react/views/stacks.ts index 713c8c2ad..741a95ff3 100644 --- a/app/docker/react/views/stacks.ts +++ b/app/docker/react/views/stacks.ts @@ -5,10 +5,15 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { ItemView } from '@/react/docker/stacks/ItemView/ItemView'; +import { CreateView } from '@/react/docker/stacks/CreateView/CreateView'; export const stacksModule = angular .module('portainer.docker.stacks', []) .component( 'stackItemView', r2a(withUIRouter(withReactQuery(withCurrentUser(ItemView))), []) + ) + .component( + 'createStackView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateView))), []) ).name; diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js index 7cd4297d3..cbace4424 100644 --- a/app/portainer/helpers/stackHelper.js +++ b/app/portainer/helpers/stackHelper.js @@ -1,6 +1,6 @@ import _ from 'lodash-es'; import { ExternalStackViewModel } from '@/react/docker/stacks/view-models/external-stack'; -import { validateYAML } from '@/react/docker/stacks/ItemView/StackEditorTab/stackYamlValidation'; +import { validateYAML } from '@/react/docker/stacks/common/stackYamlValidation'; angular.module('portainer.app').factory('StackHelper', [ function StackHelperFactory() { diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js deleted file mode 100644 index 118e2d23a..000000000 --- a/app/portainer/views/stacks/create/createStackController.js +++ /dev/null @@ -1,380 +0,0 @@ -import angular from 'angular'; - -import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; -import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; -import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; -import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods'; -import { confirmWebEditorDiscard } from '@@/modals/confirm'; -import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; -import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; -import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; -import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema'; - -angular - .module('portainer.app') - .controller( - 'CreateStackController', - function ( - $scope, - $state, - $async, - $window, - StackService, - Authentication, - Notifications, - FormValidator, - ResourceControlService, - FormHelper, - StackHelper, - ContainerHelper, - ContainerService, - endpoint - ) { - $scope.onChangeTemplateId = onChangeTemplateId; - $scope.onChangeTemplateVariables = onChangeTemplateVariables; - $scope.isTemplateVariablesEnabled = isTemplateVariablesEnabled; - $scope.buildAnalyticsProperties = buildAnalyticsProperties; - $scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK; - $scope.buildMethods = [editor, upload, git, customTemplate]; - $scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX; - - $scope.formValues = { - Name: '', - StackFileContent: '', - StackFile: null, - RepositoryURL: '', - RepositoryReferenceName: '', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - Env: [], - AdditionalFiles: [], - ComposeFilePathInRepository: 'docker-compose.yml', - AccessControlData: new AccessControlFormData(), - EnableWebhook: false, - Variables: [], - AutoUpdate: parseAutoUpdateResponse(), - TLSSkipVerify: false, - }; - - $scope.state = { - Method: 'editor', - formValidationError: '', - actionInProgress: false, - StackType: null, - editorYamlValidationError: '', - uploadYamlValidationError: '', - isEditorDirty: false, - selectedTemplate: null, - selectedTemplateId: null, - baseWebhookUrl: baseStackWebhookUrl(), - webhookId: createWebhookId(), - templateLoadFailed: false, - isEditorReadOnly: false, - }; - - $scope.currentUser = { - isAdmin: false, - id: null, - }; - - $window.onbeforeunload = () => { - if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) { - return ''; - } - }; - - $scope.$on('$destroy', function () { - $scope.state.isEditorDirty = false; - }); - - $scope.onChangeFormValues = onChangeFormValues; - $scope.onBuildMethodChange = onBuildMethodChange; - - function onBuildMethodChange(value) { - $scope.$evalAsync(() => { - $scope.state.Method = value; - }); - } - - $scope.onEnableWebhookChange = function (enable) { - $scope.$evalAsync(() => { - $scope.formValues.EnableWebhook = enable; - }); - }; - - function buildAnalyticsProperties() { - const metadata = { type: methodLabel($scope.state.Method) }; - - if ($scope.state.Method === 'repository') { - metadata.automaticUpdates = 'off'; - if ($scope.formValues.RepositoryAutomaticUpdates) { - metadata.automaticUpdates = autoSyncLabel($scope.formValues.RepositoryMechanism); - } - metadata.auth = $scope.formValues.RepositoryAuthentication; - } - - if ($scope.state.Method === 'template') { - metadata.templateName = $scope.state.selectedTemplate.Title; - } - - return { metadata }; - - function methodLabel(method) { - switch (method) { - case 'editor': - return 'web-editor'; - case 'repository': - return 'git'; - case 'upload': - return 'file-upload'; - case 'template': - return 'custom-template'; - } - } - - function autoSyncLabel(type) { - switch (type) { - case RepositoryMechanismTypes.INTERVAL: - return 'polling'; - case RepositoryMechanismTypes.WEBHOOK: - return 'webhook'; - } - return 'off'; - } - } - - function validateForm(accessControlData, isAdmin) { - $scope.state.formValidationError = ''; - var error = ''; - error = FormValidator.validateAccessControl(accessControlData, isAdmin); - - if (error) { - $scope.state.formValidationError = error; - return false; - } - return true; - } - - function createSwarmStack(name, method) { - var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); - const endpointId = +$state.params.endpointId; - - if (method === 'template' || method === 'editor') { - var stackFileContent = $scope.formValues.StackFileContent; - return StackService.createSwarmStackFromFileContent(name, stackFileContent, env, endpointId); - } - - if (method === 'upload') { - var stackFile = $scope.formValues.StackFile; - return StackService.createSwarmStackFromFileUpload(name, stackFile, env, endpointId); - } - - if (method === 'repository') { - var repositoryOptions = { - AdditionalFiles: $scope.formValues.AdditionalFiles, - RepositoryURL: $scope.formValues.RepositoryURL, - RepositoryReferenceName: $scope.formValues.RepositoryReferenceName, - ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository, - RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, - RepositoryUsername: $scope.formValues.RepositoryUsername, - RepositoryPassword: $scope.formValues.RepositoryPassword, - AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId), - TLSSkipVerify: $scope.formValues.TLSSkipVerify, - }; - - return StackService.createSwarmStackFromGitRepository(name, repositoryOptions, env, endpointId); - } - } - - function createComposeStack(name, method) { - var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); - const endpointId = +$state.params.endpointId; - - if (method === 'editor' || method === 'template') { - var stackFileContent = $scope.formValues.StackFileContent; - return StackService.createComposeStackFromFileContent(name, stackFileContent, env, endpointId); - } else if (method === 'upload') { - var stackFile = $scope.formValues.StackFile; - return StackService.createComposeStackFromFileUpload(name, stackFile, env, endpointId); - } else if (method === 'repository') { - var repositoryOptions = { - AdditionalFiles: $scope.formValues.AdditionalFiles, - RepositoryURL: $scope.formValues.RepositoryURL, - RepositoryReferenceName: $scope.formValues.RepositoryReferenceName, - ComposeFilePathInRepository: $scope.formValues.ComposeFilePathInRepository, - RepositoryAuthentication: $scope.formValues.RepositoryAuthentication, - RepositoryUsername: $scope.formValues.RepositoryUsername, - RepositoryPassword: $scope.formValues.RepositoryPassword, - AutoUpdate: transformAutoUpdateViewModel($scope.formValues.AutoUpdate, $scope.state.webhookId), - TLSSkipVerify: $scope.formValues.TLSSkipVerify, - }; - - return StackService.createComposeStackFromGitRepository(name, repositoryOptions, env, endpointId); - } - } - - $scope.handleEnvVarChange = handleEnvVarChange; - function handleEnvVarChange(value) { - $scope.formValues.Env = value; - } - - $scope.deployStack = function () { - var name = $scope.formValues.Name; - var method = $scope.state.Method; - - var accessControlData = $scope.formValues.AccessControlData; - var userDetails = Authentication.getUserDetails(); - var isAdmin = Authentication.isAdmin(); - - if (method === 'editor' && $scope.formValues.StackFileContent === '') { - $scope.state.formValidationError = 'Stack file content must not be empty'; - return; - } - - if (!validateForm(accessControlData, isAdmin)) { - return; - } - - var type = $scope.state.StackType; - var action = createSwarmStack; - if (type === 2) { - action = createComposeStack; - } - $scope.state.actionInProgress = true; - action(name, method) - .then(function success(data) { - if (data.data) { - data = data.data; - } - const userId = userDetails.ID; - const resourceControl = data.ResourceControl; - return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); - }) - .then(function success() { - Notifications.success('Success', 'Stack successfully deployed'); - $scope.state.isEditorDirty = false; - $state.go('docker.stacks'); - }) - .catch(function error(err) { - Notifications.error('Deployment error', err, 'Unable to deploy stack'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - }; - - $scope.onChangeFileContent = onChangeFileContent; - function onChangeFileContent(value) { - $scope.formValues.StackFileContent = value; - $scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames); - $scope.state.isEditorDirty = true; - } - - async function onFileLoadAsync(event) { - $scope.state.uploadYamlValidationError = StackHelper.validateYAML(event.target.result, $scope.containerNames); - } - - function onFileLoad(event) { - return $async(onFileLoadAsync, event); - } - - $scope.uploadFile = function (file) { - $scope.formValues.StackFile = file; - - if (file) { - const temporaryFileReader = new FileReader(); - temporaryFileReader.fileName = file.name; - temporaryFileReader.onload = onFileLoad; - temporaryFileReader.readAsText(file); - } - }; - - function onChangeTemplateId(templateId, template) { - return $async(async () => { - if (!template || ($scope.state.selectedTemplateId === templateId && $scope.state.selectedTemplate === template)) { - return; - } - - try { - $scope.state.selectedTemplateId = templateId; - $scope.state.selectedTemplate = template; - - try { - const isGit = template.GitConfig !== null; - $scope.state.templateContent = await this.CustomTemplateService.customTemplateFile(templateId, isGit); - onChangeFileContent($scope.state.templateContent); - - $scope.state.isEditorReadOnly = isGit; - } catch (err) { - $scope.state.templateLoadFailed = true; - throw err; - } - - if (template.Variables && template.Variables.length > 0) { - const variables = getVariablesFieldDefaultValues(template.Variables); - onChangeTemplateVariables(variables); - } - } catch (err) { - Notifications.error('Failure', err, 'Unable to retrieve Custom Template file'); - } - }); - } - - function onChangeTemplateVariables(value) { - onChangeFormValues({ Variables: value }); - - if (!$scope.isTemplateVariablesEnabled) { - return; - } - const rendered = renderTemplate($scope.state.templateContent, $scope.formValues.Variables, $scope.state.selectedTemplate.Variables); - $scope.state.editorYamlValidationError = StackHelper.validateYAML(rendered, $scope.containerNames); - onChangeFormValues({ StackFileContent: rendered }); - } - - async function initView() { - $scope.currentUser.isAdmin = Authentication.isAdmin(); - $scope.currentUser.id = Authentication.getUserDetails().ID; - - var endpointMode = $scope.applicationState.endpoint.mode; - $scope.state.StackType = 2; - $scope.isDockerStandalone = endpointMode.provider === 'DOCKER_STANDALONE'; - if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') { - $scope.state.StackType = 1; - } - - $scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion; - try { - const containers = await ContainerService.containers(endpoint.Id, true); - $scope.containerNames = ContainerHelper.getContainerNames(containers); - } catch (err) { - Notifications.error('Failure', err, 'Unable to retrieve Containers'); - } - - try { - $scope.dockerComposeSchema = await getDockerComposeSchema(); - } catch (err) { - Notifications.error('Failure', err, 'Unable to load schema validation for editor'); - } - } - - this.uiCanExit = async function () { - if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) { - return confirmWebEditorDiscard(); - } - }; - - initView(); - - function onChangeFormValues(newValues) { - return $async(async () => { - $scope.formValues = { - ...$scope.formValues, - ...newValues, - }; - }); - } - } - ); diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html deleted file mode 100644 index 25773d35a..000000000 --- a/app/portainer/views/stacks/create/createstack.html +++ /dev/null @@ -1,193 +0,0 @@ - - -
-
- - -
- -
- -
- -
-
-
-

- - This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123'). -

-
-
-
-
-
- -
- - This stack will be deployed using the equivalent of the docker stack deploy command. - -
-
- This stack will be deployed using the equivalent of docker compose. Only Compose file format version 2 is supported at the moment. -
- - Note: Due to a limitation of libcompose, the name of the stack will be standardized to remove all special characters and uppercase letters. -
- - This stack will be deployed using docker compose. - -
- -
Build method
- - - - - - -
-
Upload
-
- You can upload a Compose file from your computer. -
{{ state.uploadYamlValidationError }}
-
-
-
- - - {{ formValues.StackFile.name }} - - -
-
-
- - - -
-
- - - - - - -

- Custom template could not be loaded, please - click here for configuration.

-

- Custom template could not be loaded, please contact your administrator.

-
-
-
-
- - - -

- You can get more information about Compose file format in the official documentation. -

-

{{ state.editorYamlValidationError }}

-
-
- -
-
- Webhooks - -
-
- - - - - - -
Actions
-
-
- - {{ state.formValidationError }} -
-
- -
-
-
-
-
diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index 1a8be5cfb..d3654dd9b 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -88,7 +88,9 @@ export function createMockResourceGroups(subscription: string, count: number) { return { value: resourceGroups }; } -export function createMockEnvironment(): Environment { +export function createMockEnvironment( + overrides: Partial = {} +): Environment { return { TagIds: [], GroupId: 1, @@ -153,6 +155,7 @@ export function createMockEnvironment(): Environment { detail: '', summary: '', }, + ...overrides, }; } diff --git a/app/react/common/stacks/CreateView/NameField.tsx b/app/react/common/stacks/CreateView/NameField.tsx deleted file mode 100644 index 262e6cbdb..000000000 --- a/app/react/common/stacks/CreateView/NameField.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { FormikErrors } from 'formik'; -import { SchemaOf, string } from 'yup'; -import { useMemo } from 'react'; - -import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { FormControl } from '@@/form-components/FormControl'; -import { Input } from '@@/form-components/Input'; - -import { useStacks } from '../queries/useStacks'; - -export function NameField({ - onChange, - value, - errors, - placeholder, -}: { - onChange(value: string): void; - value: string; - errors?: FormikErrors; - placeholder?: string; -}) { - return ( - - onChange(e.target.value)} - value={value} - placeholder={placeholder} - required - data-cy="stack-name-input" - /> - - ); -} - -export function useNameValidation( - environmentId: EnvironmentId -): SchemaOf { - const stacksQuery = useStacks(); - - return useMemo( - () => - string() - .required('Name is required') - .test( - 'unique', - 'Name should be unique', - (value) => - stacksQuery.data?.every( - (s) => s.EndpointId !== environmentId || s.Name !== value - ) ?? true - ) - .matches( - new RegExp(STACK_NAME_VALIDATION_REGEX), - "This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')." - ), - [environmentId, stacksQuery.data] - ); -} diff --git a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts index 0a2ea2f7a..7662e9e35 100644 --- a/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createKubernetesStackFromGit.ts @@ -1,5 +1,5 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { AutoUpdateModel } from '@/react/portainer/gitops/types'; +import { AutoUpdateResponse } from '@/react/portainer/gitops/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Stack } from '../../types'; @@ -31,7 +31,7 @@ export type KubernetesGitRepositoryPayload = { /** TLSSkipVerify skips SSL verification when cloning the Git repository */ tlsSkipVerify?: boolean; /** Optional GitOps update configuration */ - autoUpdate?: AutoUpdateModel; + autoUpdate?: AutoUpdateResponse | null; environmentId: EnvironmentId; }; diff --git a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts index d006b9cf7..2106c3e66 100644 --- a/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createStandaloneStackFromGit.ts @@ -1,6 +1,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { Pair } from '@/react/portainer/settings/types'; -import { AutoUpdateModel } from '@/react/portainer/gitops/types'; +import { AutoUpdateResponse } from '@/react/portainer/gitops/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Stack } from '../../types'; @@ -33,7 +33,7 @@ export type StandaloneGitRepositoryPayload = { additionalFiles?: Array; /** Optional GitOps update configuration */ - autoUpdate?: AutoUpdateModel; + autoUpdate?: AutoUpdateResponse | null; /** Whether the stack supports relative path volume */ supportRelativePath?: boolean; diff --git a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts index c373fd184..cd4fe2dc7 100644 --- a/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts +++ b/app/react/common/stacks/queries/useCreateStack/createSwarmStackFromGit.ts @@ -1,6 +1,6 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { Pair } from '@/react/portainer/settings/types'; -import { AutoUpdateModel } from '@/react/portainer/gitops/types'; +import { AutoUpdateResponse } from '@/react/portainer/gitops/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Stack } from '../../types'; @@ -35,7 +35,7 @@ export type SwarmGitRepositoryPayload = { additionalFiles?: Array; /** Optional GitOps update configuration */ - autoUpdate?: AutoUpdateModel; + autoUpdate?: AutoUpdateResponse | null; /** Whether the stack supports relative path volume */ supportRelativePath?: boolean; diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index b06c79966..08ed5560c 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -9,7 +9,8 @@ import { import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; import { AccessControlFormData } from '@/react/portainer/access-control/types'; import PortainerError from '@/portainer/error'; -import { withError, withInvalidate } from '@/react-tools/react-query'; +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; +import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { queryKeys } from '../query-keys'; @@ -26,7 +27,7 @@ import { createKubernetesStackFromFileContent } from './createKubernetesStackFro export function useCreateStack() { const queryClient = useQueryClient(); return useMutation(createStack, { - ...withError('Failed to create stack'), + ...withGlobalError('Failed to create stack'), ...withInvalidate(queryClient, [queryKeys.base()]), }); } @@ -194,7 +195,7 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: payload.git.AutoUpdate, + autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), environmentId: payload.environmentId, swarmID: payload.swarmId, additionalFiles: payload.git.AdditionalFiles, @@ -239,7 +240,7 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) { filesystemPath: payload.relativePathSettings?.FilesystemPath, supportRelativePath: payload.relativePathSettings?.SupportRelativePath, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: payload.git.AutoUpdate, + autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), environmentId: payload.environmentId, additionalFiles: payload.git.AdditionalFiles, fromAppTemplate: payload.fromAppTemplate, @@ -282,7 +283,7 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) { repositoryGitCredentialId: payload.git.RepositoryGitCredentialID, tlsSkipVerify: payload.git.TLSSkipVerify, - autoUpdate: payload.git.AutoUpdate, + autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate), environmentId: payload.environmentId, additionalFiles: payload.git.AdditionalFiles, composeFormat: payload.composeFormat, diff --git a/app/react/components/CodeEditor/CodeEditor.tsx b/app/react/components/CodeEditor/CodeEditor.tsx index e42d46c57..07c79842d 100644 --- a/app/react/components/CodeEditor/CodeEditor.tsx +++ b/app/react/components/CodeEditor/CodeEditor.tsx @@ -160,7 +160,7 @@ export function CodeEditor({ }} data-cy={dataCy} placeholder={placeholder} - aria-label={'Code Editor' || ariaLabel} + aria-label={ariaLabel || 'Code Editor'} /> diff --git a/app/react/components/CustomTemplateSelector.test.tsx b/app/react/components/CustomTemplateSelector.test.tsx new file mode 100644 index 000000000..e4121f32c --- /dev/null +++ b/app/react/components/CustomTemplateSelector.test.tsx @@ -0,0 +1,130 @@ +import { http, HttpResponse } from 'msw'; +import { render, waitFor } from '@testing-library/react'; +import { Mock } from 'vitest'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { StackType } from '@/react/common/stacks/types'; + +import { CustomTemplateSelector } from './CustomTemplateSelector'; + +const mockCustomTemplates: CustomTemplate[] = [ + { + Id: 1, + Title: 'Nginx Template', + Description: 'Web server template', + ProjectPath: '/data/custom_template/1', + EntryPoint: 'docker-compose.yml', + CreatedByUserId: 1, + Note: 'Nginx note', + Platform: 1, + Logo: '', + Type: StackType.DockerCompose, + Variables: [], + IsComposeFormat: false, + EdgeTemplate: false, + }, + { + Id: 2, + Title: 'Redis Template', + Description: '', + ProjectPath: '/data/custom_template/2', + EntryPoint: 'docker-compose.yml', + CreatedByUserId: 1, + Note: '', + Platform: 1, + Logo: '', + Type: StackType.DockerCompose, + Variables: [], + IsComposeFormat: false, + EdgeTemplate: false, + }, +]; + +test('should render the component with label', async () => { + const { getByText } = await renderComponent({}, []); + + expect(getByText('Template')).toBeInTheDocument(); +}); + +test('should show message when no templates are available', async () => { + const queries = await renderComponent({}, []); + + await waitFor(() => { + expect( + queries.getByText('No custom templates are available.') + ).toBeInTheDocument(); + }); +}); + +test('should show link to create template when newTemplatePath is provided', async () => { + const queries = await renderComponent( + { newTemplatePath: '/templates/new' }, + [] + ); + + await waitFor(() => { + const link = queries.getByRole('link', { name: /custom template view/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/templates/new'); + }); +}); + +test('should render select with templates', async () => { + const queries = await renderComponent({}, mockCustomTemplates); + + await waitFor(() => { + const combobox = queries.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); + }); +}); + +test('should display error message when provided', async () => { + const queries = await renderComponent( + { error: 'This field is required' }, + mockCustomTemplates + ); + + await waitFor(() => { + expect(queries.getByText('This field is required')).toBeInTheDocument(); + }); +}); + +async function renderComponent( + { + value, + onChange = vi.fn(), + error, + stackType, + newTemplatePath, + }: { + value?: number; + onChange?: Mock; + error?: string; + stackType?: StackType; + newTemplatePath?: string; + } = {}, + templates: CustomTemplate[] = [] +) { + server.use( + http.get('/api/custom_templates', () => HttpResponse.json(templates)) + ); + + const Component = withTestQueryProvider(CustomTemplateSelector); + + const queries = render( + + ); + + const labelElement = await queries.findByText('Template'); + expect(labelElement).toBeInTheDocument(); + + return queries; +} diff --git a/app/react/components/CustomTemplateSelector.tsx b/app/react/components/CustomTemplateSelector.tsx new file mode 100644 index 000000000..706e79d4f --- /dev/null +++ b/app/react/components/CustomTemplateSelector.tsx @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; + +import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates'; +import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { StackType } from '@/react/common/stacks/types'; + +import { FormControl } from '@@/form-components/FormControl'; +import { PortainerSelect } from '@@/form-components/PortainerSelect'; + +interface Props { + value: CustomTemplate['Id'] | undefined; + onChange: (template: CustomTemplate['Id'] | undefined) => void; + + error?: string; + stackType?: StackType; + newTemplatePath?: string; +} + +export function CustomTemplateSelector({ + value, + onChange, + error, + stackType, + newTemplatePath, +}: Props) { + const inputId = 'custom-template-selector'; + + const customTemplatesQuery = useCustomTemplates({ + params: stackType ? { type: [stackType] } : {}, + }); + + const templateOptions = useMemo(() => { + if (!customTemplatesQuery.data) { + return []; + } + + return customTemplatesQuery.data.map((template) => ({ + label: `${template.Title}${ + template.Description ? ` - ${template.Description}` : '' + }`, + value: template.Id, + })); + }, [customTemplatesQuery]); + + const { isLoading } = customTemplatesQuery; + const hasTemplates = templateOptions.length > 0; + + return ( + + {hasTemplates && ( + + )} + + {!isLoading && !hasTemplates && ( + + No custom templates are available. + {newTemplatePath && ( + <> + {' '} + Head over to the{' '} + custom template view to create one. + + )} + + )} + + ); + + function handleChange(templateId: number | undefined) { + onChange(templateId); + } +} diff --git a/app/react/components/form-components/FileUpload/FileUploadField.test.tsx b/app/react/components/form-components/FileUpload/FileUploadField.test.tsx index d19f895e1..d78290abf 100644 --- a/app/react/components/form-components/FileUpload/FileUploadField.test.tsx +++ b/app/react/components/form-components/FileUpload/FileUploadField.test.tsx @@ -4,10 +4,10 @@ import { FileUploadField } from './FileUploadField'; test('render should make the file button clickable and fire onChange event after click', async () => { const onClick = vi.fn(); - const { findByText, findByLabelText } = render( + const { findByText, findByTestId } = render( @@ -16,7 +16,7 @@ test('render should make the file button clickable and fire onChange event after const button = await findByText('test button'); expect(button).toBeVisible(); - const input = await findByLabelText('file-input'); + const input = await findByTestId('file-upload-input'); expect(input).not.toBeNull(); const mockFile = new File([], 'file.txt'); diff --git a/app/react/components/form-components/FileUpload/FileUploadField.tsx b/app/react/components/form-components/FileUpload/FileUploadField.tsx index 0122ee16b..736c110b1 100644 --- a/app/react/components/form-components/FileUpload/FileUploadField.tsx +++ b/app/react/components/form-components/FileUpload/FileUploadField.tsx @@ -42,8 +42,8 @@ export function FileUploadField({ required={required} className={styles.fileInput} onChange={changeHandler} - aria-label="file-input" name={name} + data-cy={`${dataCy}-input`} />