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