diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go
index 91f09cfd4..7baed4983 100644
--- a/api/http/handler/customtemplates/customtemplate_update.go
+++ b/api/http/handler/customtemplates/customtemplate_update.go
@@ -211,10 +211,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.GitConfig = gitConfig
} else {
templateFolder := strconv.Itoa(customTemplateID)
- _, err = handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
+ projectPath, err := handler.FileService.StoreCustomTemplateFileFromBytes(templateFolder, customTemplate.EntryPoint, []byte(payload.FileContent))
if err != nil {
return httperror.InternalServerError("Unable to persist updated custom template file on disk", err)
}
+
+ customTemplate.ProjectPath = projectPath
}
err = handler.DataStore.CustomTemplate().Update(customTemplate.ID, customTemplate)
diff --git a/app/constants.ts b/app/constants.ts
index 2dbcd1153..8a8e687d2 100644
--- a/app/constants.ts
+++ b/app/constants.ts
@@ -27,6 +27,3 @@ export const CONSOLE_COMMANDS_LABEL_PREFIX = 'io.portainer.commands.';
export const PREDEFINED_NETWORKS = ['host', 'bridge', 'ingress', 'nat', 'none'];
export const PORTAINER_FADEOUT = 1500;
export const STACK_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
-export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
-export const KUBE_TEMPLATE_NAME_VALIDATION_REGEX =
- '^(([a-z0-9](?:(?:[-a-z0-9_.]){0,61}[a-z0-9])?))$'; // alphanumeric, lowercase, can contain dashes, dots and underscores, max 63 characters
diff --git a/app/docker/__module.js b/app/docker/__module.js
index 062026ac9..254c6bfc7 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -137,7 +137,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
views: {
'content@': {
- component: 'editCustomTemplateView',
+ component: 'editCustomTemplatesView',
},
},
};
diff --git a/app/edge/__module.js b/app/edge/__module.js
index 131a4f039..0a4b416a6 100644
--- a/app/edge/__module.js
+++ b/app/edge/__module.js
@@ -185,7 +185,7 @@ angular
views: {
'content@': {
- component: 'edgeEditCustomTemplatesView',
+ component: 'editCustomTemplatesView',
},
},
});
diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts
index 3b8fe2044..570875483 100644
--- a/app/edge/react/views/templates.ts
+++ b/app/edge/react/views/templates.ts
@@ -4,9 +4,9 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
-import { EditView as EdgeEditView } from '@/react/edge/templates/custom-templates/EditView';
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
-import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView/CreateView';
+import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
+import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
export const templatesModule = angular
.module('portainer.app.react.components.templates', [])
@@ -23,6 +23,6 @@ export const templatesModule = angular
r2a(withCurrentUser(withUIRouter(CreateView)), [])
)
.component(
- 'edgeEditCustomTemplatesView',
- r2a(withCurrentUser(withUIRouter(EdgeEditView)), [])
+ 'editCustomTemplatesView',
+ r2a(withCurrentUser(withUIRouter(EditView)), [])
).name;
diff --git a/app/kubernetes/custom-templates/index.js b/app/kubernetes/custom-templates/index.js
index ef33784cb..4183cf551 100644
--- a/app/kubernetes/custom-templates/index.js
+++ b/app/kubernetes/custom-templates/index.js
@@ -1,13 +1,8 @@
import angular from 'angular';
import { kubeCustomTemplatesView } from './kube-custom-templates-view';
-import { kubeEditCustomTemplateView } from './kube-edit-custom-template-view';
-export default angular
- .module('portainer.kubernetes.custom-templates', [])
- .config(config)
- .component('kubeCustomTemplatesView', kubeCustomTemplatesView)
- .component('kubeEditCustomTemplateView', kubeEditCustomTemplateView).name;
+export default angular.module('portainer.kubernetes.custom-templates', []).config(config).component('kubeCustomTemplatesView', kubeCustomTemplatesView).name;
function config($stateRegistryProvider) {
const templates = {
@@ -50,7 +45,7 @@ function config($stateRegistryProvider) {
views: {
'content@': {
- component: 'kubeEditCustomTemplateView',
+ component: 'editCustomTemplatesView',
},
},
};
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js b/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js
deleted file mode 100644
index 8e143d9c5..000000000
--- a/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import controller from './kube-edit-custom-template-view.controller.js';
-
-export const kubeEditCustomTemplateView = {
- templateUrl: './kube-edit-custom-template-view.html',
- controller,
-};
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js
deleted file mode 100644
index 3d74acb4b..000000000
--- a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js
+++ /dev/null
@@ -1,285 +0,0 @@
-import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
-import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
-import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
-import { confirmWebEditorDiscard } from '@@/modals/confirm';
-import { getFilePreview } from '@/react/portainer/gitops/gitops.service';
-import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
-
-class KubeEditCustomTemplateViewController {
- /* @ngInject */
- constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
- Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
-
- this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
-
- this.formValues = {
- Variables: [],
- TLSSkipVerify: false,
- Title: '',
- Description: '',
- Note: '',
- Logo: '',
- };
- this.state = {
- formValidationError: '',
- isEditorDirty: false,
- isTemplateValid: true,
- isEditorReadOnly: false,
- templateLoadFailed: false,
- templatePreviewFailed: false,
- templatePreviewError: '',
- };
- this.templates = [];
-
- this.validationData = {
- title: {
- pattern: KUBE_TEMPLATE_NAME_VALIDATION_REGEX,
- error:
- "This field must consist of lower-case alphanumeric characters, '.', '_' or '-', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').",
- },
- };
-
- this.getTemplate = this.getTemplate.bind(this);
- this.submitAction = this.submitAction.bind(this);
- this.onChangeFileContent = this.onChangeFileContent.bind(this);
- this.onBeforeUnload = this.onBeforeUnload.bind(this);
- this.handleChange = this.handleChange.bind(this);
- this.onVariablesChange = this.onVariablesChange.bind(this);
- this.previewFileFromGitRepository = this.previewFileFromGitRepository.bind(this);
- this.onChangePlatform = this.onChangePlatform.bind(this);
- this.onChangeType = this.onChangeType.bind(this);
- }
-
- onChangePlatform(value) {
- this.handleChange({ Platform: value });
- }
-
- onChangeType(value) {
- this.handleChange({ Type: value });
- }
-
- getTemplate() {
- return this.$async(async () => {
- try {
- const { id } = this.$state.params;
-
- const template = await this.CustomTemplateService.customTemplate(id);
-
- if (template.GitConfig !== null) {
- this.state.isEditorReadOnly = true;
- }
-
- try {
- template.FileContent = await this.CustomTemplateService.customTemplateFile(id, template.GitConfig !== null);
- } catch (err) {
- this.state.templateLoadFailed = true;
- throw err;
- }
-
- template.Variables = template.Variables || [];
-
- this.formValues = { ...this.formValues, ...template };
-
- this.parseTemplate(template.FileContent);
- this.parseGitConfig(template.GitConfig);
-
- this.oldFileContent = this.formValues.FileContent;
-
- this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
- this.formValues.AccessControlData = new AccessControlFormData();
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
- }
- });
- }
-
- onVariablesChange(values) {
- this.handleChange({ Variables: values });
- }
-
- handleChange(values) {
- return this.$async(async () => {
- this.formValues = {
- ...this.formValues,
- ...values,
- };
- });
- }
-
- parseTemplate(templateStr) {
- if (!this.isTemplateVariablesEnabled) {
- return;
- }
-
- const [variables] = getTemplateVariables(templateStr);
-
- const isValid = !!variables;
-
- this.state.isTemplateValid = isValid;
-
- if (isValid) {
- this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
- }
- }
-
- parseGitConfig(config) {
- if (config === null) {
- return;
- }
-
- let flatConfig = {
- RepositoryURL: config.URL,
- RepositoryReferenceName: config.ReferenceName,
- ComposeFilePathInRepository: config.ConfigFilePath,
- RepositoryAuthentication: config.Authentication !== null,
- TLSSkipVerify: config.TLSSkipVerify,
- };
-
- if (config.Authentication) {
- flatConfig = {
- ...flatConfig,
- RepositoryUsername: config.Authentication.Username,
- RepositoryPassword: config.Authentication.Password,
- };
- }
-
- this.formValues = { ...this.formValues, ...flatConfig };
- }
-
- previewFileFromGitRepository() {
- this.state.templatePreviewFailed = false;
- this.state.templatePreviewError = '';
-
- let creds = {};
- if (this.formValues.RepositoryAuthentication) {
- creds = {
- username: this.formValues.RepositoryUsername,
- password: this.formValues.RepositoryPassword,
- };
- }
- const payload = {
- repository: this.formValues.RepositoryURL,
- targetFile: this.formValues.ComposeFilePathInRepository,
- tlsSkipVerify: this.formValues.TLSSkipVerify,
- ...creds,
- };
-
- this.$async(async () => {
- try {
- this.formValues.FileContent = await getFilePreview(payload);
- this.state.isEditorDirty = true;
-
- // check if the template contains mustache template symbol
- this.parseTemplate(this.formValues.FileContent);
- } catch (err) {
- this.state.templatePreviewError = err.message;
- this.state.templatePreviewFailed = true;
- }
- });
- }
-
- validateForm() {
- this.state.formValidationError = '';
-
- if (!this.formValues.FileContent) {
- this.state.formValidationError = 'Template file content must not be empty';
- return false;
- }
-
- const title = this.formValues.Title;
- const id = this.$state.params.id;
-
- const isNotUnique = this.templates.some((template) => template.Title === title && template.Id != id);
- if (isNotUnique) {
- this.state.formValidationError = `A template with the name ${title} already exists`;
- return false;
- }
-
- const isAdmin = this.Authentication.isAdmin();
- const accessControlData = this.formValues.AccessControlData;
- const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
-
- if (error) {
- this.state.formValidationError = error;
- return false;
- }
-
- return true;
- }
-
- submitAction() {
- return this.$async(async () => {
- if (!this.validateForm()) {
- return;
- }
-
- this.actionInProgress = true;
- try {
- await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
-
- const userDetails = this.Authentication.getUserDetails();
- const userId = userDetails.ID;
- await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
-
- this.Notifications.success('Success', 'Custom template successfully updated');
- this.state.isEditorDirty = false;
- this.$state.go('kubernetes.templates.custom');
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to update custom template');
- } finally {
- this.actionInProgress = false;
- }
- });
- }
-
- onChangeFileContent(value) {
- if (stripSpaces(this.formValues.FileContent) !== stripSpaces(value)) {
- this.formValues.FileContent = value;
- this.parseTemplate(value);
- this.state.isEditorDirty = true;
- }
- }
-
- async $onInit() {
- this.$async(async () => {
- this.getTemplate();
-
- try {
- this.templates = await this.CustomTemplateService.customTemplates();
- } catch (err) {
- this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
- }
-
- window.addEventListener('beforeunload', this.onBeforeUnload);
- });
- }
-
- isEditorDirty() {
- return this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty;
- }
-
- uiCanExit() {
- if (this.isEditorDirty()) {
- return confirmWebEditorDiscard();
- }
- }
-
- onBeforeUnload(event) {
- if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
- event.preventDefault();
- event.returnValue = '';
-
- return '';
- }
- }
-
- $onDestroy() {
- window.removeEventListener('beforeunload', this.onBeforeUnload);
- }
-}
-
-export default KubeEditCustomTemplateViewController;
-
-function stripSpaces(str = '') {
- return str.replace(/(\r\n|\n|\r)/gm, '');
-}
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html
deleted file mode 100644
index 3a5c68d24..000000000
--- a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
diff --git a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js
index 286591b8b..f610a0940 100644
--- a/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js
+++ b/app/portainer/views/custom-templates/custom-templates-view/customTemplatesViewController.js
@@ -1,9 +1,9 @@
import _ from 'lodash-es';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
-import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { confirmDelete } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/react/portainer/custom-templates/components/CommonFields';
class CustomTemplatesViewController {
/* @ngInject */
diff --git a/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateView.html b/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateView.html
deleted file mode 100644
index ada199ecd..000000000
--- a/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateView.html
+++ /dev/null
@@ -1,98 +0,0 @@
-
-
-
diff --git a/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js b/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js
deleted file mode 100644
index ed9d7b543..000000000
--- a/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js
+++ /dev/null
@@ -1,274 +0,0 @@
-import _ from 'lodash';
-import { getFilePreview } from '@/react/portainer/gitops/gitops.service';
-import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
-import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
-
-import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
-import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
-import { confirmWebEditorDiscard } from '@@/modals/confirm';
-
-class EditCustomTemplateViewController {
- /* @ngInject */
- constructor($async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
- Object.assign(this, { $async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
-
- this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
-
- this.formValues = {
- Variables: [],
- TLSSkipVerify: false,
- Title: '',
- Description: '',
- Note: '',
- Logo: '',
- };
-
- this.state = {
- formValidationError: '',
- isEditorDirty: false,
- isTemplateValid: true,
- isEditorReadOnly: false,
- templateLoadFailed: false,
- templatePreviewFailed: false,
- templatePreviewError: '',
- };
-
- this.validationData = {
- title: {
- pattern: TEMPLATE_NAME_VALIDATION_REGEX,
- error: "This field must consist of lower-case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').",
- },
- };
-
- this.templates = [];
-
- this.getTemplate = this.getTemplate.bind(this);
- this.getTemplateAsync = this.getTemplateAsync.bind(this);
- this.submitAction = this.submitAction.bind(this);
- this.submitActionAsync = this.submitActionAsync.bind(this);
- this.editorUpdate = this.editorUpdate.bind(this);
- this.onVariablesChange = this.onVariablesChange.bind(this);
- this.handleChange = this.handleChange.bind(this);
- this.previewFileFromGitRepository = this.previewFileFromGitRepository.bind(this);
- this.onChangePlatform = this.onChangePlatform.bind(this);
- this.onChangeType = this.onChangeType.bind(this);
- }
-
- onChangePlatform(value) {
- this.handleChange({ Platform: value });
- }
-
- onChangeType(value) {
- this.handleChange({ Type: value });
- }
-
- getTemplate() {
- return this.$async(this.getTemplateAsync);
- }
- async getTemplateAsync() {
- try {
- const template = await this.CustomTemplateService.customTemplate(this.$state.params.id);
-
- if (template.GitConfig !== null) {
- this.state.isEditorReadOnly = true;
- }
-
- try {
- template.FileContent = await this.CustomTemplateService.customTemplateFile(this.$state.params.id, template.GitConfig !== null);
- } catch (err) {
- this.state.templateLoadFailed = true;
- throw err;
- }
-
- template.Variables = template.Variables || [];
-
- this.formValues = { ...this.formValues, ...template };
-
- this.parseTemplate(template.FileContent);
- this.parseGitConfig(template.GitConfig);
-
- this.oldFileContent = this.formValues.FileContent;
- if (template.ResourceControl) {
- this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
- }
- this.formValues.AccessControlData = new AccessControlFormData();
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
- }
- }
-
- onVariablesChange(value) {
- this.handleChange({ Variables: value });
- }
-
- handleChange(values) {
- return this.$async(async () => {
- this.formValues = {
- ...this.formValues,
- ...values,
- };
- });
- }
-
- validateForm() {
- this.state.formValidationError = '';
-
- if (!this.formValues.FileContent) {
- this.state.formValidationError = 'Template file content must not be empty';
- return false;
- }
-
- const title = this.formValues.Title;
- const id = this.$state.params.id;
- const isNotUnique = _.some(this.templates, (template) => template.Title === title && template.Id != id);
- if (isNotUnique) {
- this.state.formValidationError = `A template with the name ${title} already exists`;
- return false;
- }
-
- const isAdmin = this.Authentication.isAdmin();
- const accessControlData = this.formValues.AccessControlData;
- const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
-
- if (error) {
- this.state.formValidationError = error;
- return false;
- }
-
- return true;
- }
-
- submitAction() {
- return this.$async(this.submitActionAsync);
- }
- async submitActionAsync() {
- if (!this.validateForm()) {
- return;
- }
-
- this.actionInProgress = true;
- try {
- await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
-
- const userDetails = this.Authentication.getUserDetails();
- const userId = userDetails.ID;
- await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
-
- this.Notifications.success('Success', 'Custom template successfully updated');
- this.state.isEditorDirty = false;
- this.$state.go('docker.templates.custom');
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to update custom template');
- } finally {
- this.actionInProgress = false;
- }
- }
-
- editorUpdate(value) {
- if (this.formValues.FileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) {
- this.formValues.FileContent = value;
- this.parseTemplate(value);
- this.state.isEditorDirty = true;
- }
- }
-
- parseTemplate(templateStr) {
- if (!this.isTemplateVariablesEnabled) {
- return;
- }
-
- const [variables] = getTemplateVariables(templateStr);
-
- const isValid = !!variables;
-
- this.state.isTemplateValid = isValid;
-
- if (isValid) {
- this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
- }
- }
-
- parseGitConfig(config) {
- if (config === null) {
- return;
- }
-
- let flatConfig = {
- RepositoryURL: config.URL,
- RepositoryReferenceName: config.ReferenceName,
- ComposeFilePathInRepository: config.ConfigFilePath,
- RepositoryAuthentication: config.Authentication !== null,
- TLSSkipVerify: config.TLSSkipVerify,
- };
-
- if (config.Authentication) {
- flatConfig = {
- ...flatConfig,
- RepositoryUsername: config.Authentication.Username,
- RepositoryPassword: config.Authentication.Password,
- };
- }
-
- this.formValues = { ...this.formValues, ...flatConfig };
- }
-
- previewFileFromGitRepository() {
- this.state.templatePreviewFailed = false;
- this.state.templatePreviewError = '';
-
- let creds = {};
- if (this.formValues.RepositoryAuthentication) {
- creds = {
- username: this.formValues.RepositoryUsername,
- password: this.formValues.RepositoryPassword,
- };
- }
- const payload = {
- repository: this.formValues.RepositoryURL,
- targetFile: this.formValues.ComposeFilePathInRepository,
- tlsSkipVerify: this.formValues.TLSSkipVerify,
- ...creds,
- };
-
- this.$async(async () => {
- try {
- this.formValues.FileContent = await getFilePreview(payload);
- this.state.isEditorDirty = true;
-
- // check if the template contains mustache template symbol
- this.parseTemplate(this.formValues.FileContent);
- } catch (err) {
- this.state.templatePreviewError = err.message;
- this.state.templatePreviewFailed = true;
- }
- });
- }
-
- async uiCanExit() {
- if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
- return confirmWebEditorDiscard();
- }
- }
-
- async $onInit() {
- this.getTemplate();
-
- try {
- this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
- } catch (err) {
- this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
- }
-
- this.$window.onbeforeunload = () => {
- if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
- return '';
- }
- };
- }
-
- $onDestroy() {
- this.state.isEditorDirty = false;
- }
-}
-
-export default EditCustomTemplateViewController;
diff --git a/app/portainer/views/custom-templates/edit-custom-template-view/index.js b/app/portainer/views/custom-templates/edit-custom-template-view/index.js
deleted file mode 100644
index f737d0a83..000000000
--- a/app/portainer/views/custom-templates/edit-custom-template-view/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import EditCustomTemplateViewController from './editCustomTemplateViewController.js';
-
-angular.module('portainer.app').component('editCustomTemplateView', {
- templateUrl: './editCustomTemplateView.html',
- controller: EditCustomTemplateViewController,
-});
diff --git a/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx b/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx
deleted file mode 100644
index 2727bc0d3..000000000
--- a/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { Formik } from 'formik';
-import { useRouter } from '@uirouter/react';
-
-import { notifySuccess } from '@/portainer/services/notifications';
-import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
-import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
-import { useUpdateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation';
-import {
- getTemplateVariables,
- intersectVariables,
- isTemplateVariablesEnabled,
-} from '@/react/portainer/custom-templates/components/utils';
-import { toGitFormModel } from '@/react/portainer/gitops/types';
-import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
-
-import { toGitRequest } from '../common/git';
-
-import { InnerForm } from './InnerForm';
-import { FormValues } from './types';
-import { useValidation } from './useValidation';
-
-export function EditTemplateForm({ template }: { template: CustomTemplate }) {
- const mutation = useUpdateTemplateMutation();
- const router = useRouter();
- const isGit = !!template.GitConfig;
- const validation = useValidation(template.Id, isGit);
- const fileQuery = useCustomTemplateFile(template.Id, isGit);
- const { saveCredentials, isLoading: isSaveCredentialsLoading } =
- useSaveCredentialsIfRequired();
-
- if (fileQuery.isLoading) {
- return null;
- }
-
- const initialValues: FormValues = {
- Title: template.Title,
- Type: template.Type,
- Description: template.Description,
- Note: template.Note,
- Logo: template.Logo,
- Platform: template.Platform,
- Variables: parseTemplate(fileQuery.data || ''),
-
- FileContent: fileQuery.data || '',
- Git: template.GitConfig ? toGitFormModel(template.GitConfig) : undefined,
- EdgeSettings: template.EdgeSettings,
- };
-
- return (
-
-
-
- );
-
- async function handleSubmit(values: FormValues) {
- const credentialId = await saveCredentials(values.Git);
-
- mutation.mutate(
- {
- id: template.Id,
- EdgeTemplate: template.EdgeTemplate,
- Description: values.Description,
- Title: values.Title,
- Type: values.Type,
- Logo: values.Logo,
- FileContent: values.FileContent,
- Note: values.Note,
- Platform: values.Platform,
- Variables: values.Variables,
- EdgeSettings: values.EdgeSettings,
- ...(values.Git ? toGitRequest(values.Git, credentialId) : {}),
- },
- {
- onSuccess() {
- notifySuccess('Success', 'Template updated successfully');
- router.stateService.go('^');
- },
- }
- );
- }
-
- function parseTemplate(templateContent: string) {
- if (!isTemplateVariablesEnabled) {
- return template.Variables;
- }
-
- const [variables] = getTemplateVariables(templateContent);
-
- if (!variables) {
- return template.Variables;
- }
-
- return intersectVariables(template.Variables, variables);
- }
-}
diff --git a/app/react/edge/templates/custom-templates/EditView/EditView.tsx b/app/react/edge/templates/custom-templates/EditView/EditView.tsx
deleted file mode 100644
index b355bb9ea..000000000
--- a/app/react/edge/templates/custom-templates/EditView/EditView.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
-import { useEffect } from 'react';
-
-import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
-import { notifyError } from '@/portainer/services/notifications';
-
-import { PageHeader } from '@@/PageHeader';
-import { Widget } from '@@/Widget';
-
-import { EditTemplateForm } from './EditTemplateForm';
-
-export function EditView() {
- const router = useRouter();
- const {
- params: { id: templateId },
- } = useCurrentStateAndParams();
- const customTemplateQuery = useCustomTemplate(templateId);
-
- useEffect(() => {
- if (customTemplateQuery.data && !customTemplateQuery.data.EdgeTemplate) {
- notifyError('Error', new Error('Trying to load non edge template'));
- router.stateService.go('^');
- }
- }, [customTemplateQuery.data, router.stateService]);
-
- if (!customTemplateQuery.data) {
- return null;
- }
-
- const template = customTemplateQuery.data;
-
- return (
- <>
-
-
- >
- );
-}
diff --git a/app/react/portainer/custom-templates/components/CommonFields.tsx b/app/react/portainer/custom-templates/components/CommonFields.tsx
index 3a2f1f43a..16ff5585b 100644
--- a/app/react/portainer/custom-templates/components/CommonFields.tsx
+++ b/app/react/portainer/custom-templates/components/CommonFields.tsx
@@ -91,33 +91,52 @@ export function CommonFields({
export function validation({
currentTemplateId,
templates = [],
- title,
+ viewType = 'docker',
}: {
currentTemplateId?: CustomTemplate['Id'];
templates?: Array;
- title?: { pattern: string; error: string };
+ viewType?: 'kube' | 'docker' | 'edge';
} = {}): SchemaOf {
- let titleSchema = string()
- .required('Title is required.')
- .test(
- 'is-unique',
- 'Title must be unique',
- (value) =>
- !value ||
- !templates.some(
- (template) =>
- template.Title === value && template.Id !== currentTemplateId
- )
- );
- if (title?.pattern) {
- const pattern = new RegExp(title.pattern);
- titleSchema = titleSchema.matches(pattern, title.error);
- }
+ const titlePattern = titlePatternValidation(viewType);
return object({
- Title: titleSchema,
+ Title: string()
+ .required('Title is required.')
+ .test(
+ 'is-unique',
+ 'Title must be unique',
+ (value) =>
+ !value ||
+ !templates.some(
+ (template) =>
+ template.Title === value && template.Id !== currentTemplateId
+ )
+ )
+ .matches(titlePattern.pattern, titlePattern.error),
Description: string().required('Description is required.'),
Note: string().default(''),
Logo: string().default(''),
});
}
+
+export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
+
+const KUBE_TEMPLATE_NAME_VALIDATION_REGEX =
+ '^(([a-z0-9](?:(?:[-a-z0-9_.]){0,61}[a-z0-9])?))$'; // alphanumeric, lowercase, can contain dashes, dots and underscores, max 63 characters
+
+function titlePatternValidation(type: 'kube' | 'docker' | 'edge') {
+ switch (type) {
+ case 'kube':
+ return {
+ pattern: new RegExp(KUBE_TEMPLATE_NAME_VALIDATION_REGEX),
+ error:
+ "This field must consist of lower-case alphanumeric characters, '.', '_' or '-', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').",
+ };
+ default:
+ return {
+ pattern: new RegExp(TEMPLATE_NAME_VALIDATION_REGEX),
+ error:
+ "This field must consist of lower-case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').",
+ };
+ }
+}
diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
index 376b9680f..2d7a5e9da 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/CreateForm.tsx
@@ -16,19 +16,20 @@ import { InnerForm } from './InnerForm';
export function CreateForm({
environmentId,
- defaultType,
+ viewType,
}: {
environmentId?: EnvironmentId;
- defaultType: StackType;
+ viewType: 'kube' | 'docker' | 'edge';
}) {
const isEdge = !environmentId;
const router = useRouter();
const mutation = useCreateTemplateMutation();
- const validation = useValidation(isEdge);
+ const validation = useValidation({ viewType });
const buildMethods = useBuildMethods();
const initialValues = useInitialValues({
- defaultType,
+ defaultType:
+ viewType === 'kube' ? StackType.Kubernetes : StackType.DockerCompose,
isEdge,
buildMethods: buildMethods.map((method) => method.value),
});
diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
index 3b0aeb3cf..3e61a67e8 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx
@@ -1,15 +1,14 @@
-import { useCurrentStateAndParams } from '@uirouter/react';
-
-import { StackType } from '@/react/common/stacks/types';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
+import { useViewType } from '../useViewType';
+
import { CreateForm } from './CreateForm';
export function CreateView() {
- const defaultType = useDefaultType();
+ const viewType = useViewType();
const environmentId = useEnvironmentId(false);
return (
@@ -26,10 +25,7 @@ export function CreateView() {
-
+
@@ -37,15 +33,3 @@ export function CreateView() {
);
}
-
-function useDefaultType() {
- const {
- state: { name },
- } = useCurrentStateAndParams();
- if (name?.includes('kubernetes')) {
- return StackType.Kubernetes;
- }
-
- // edge or docker
- return StackType.DockerCompose;
-}
diff --git a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx
index 3c84f306f..d0bd52d28 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx
@@ -4,11 +4,7 @@ import { CommonFields } from '@/react/portainer/custom-templates/components/Comm
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
import { GitForm } from '@/react/portainer/gitops/GitForm';
-import {
- getTemplateVariables,
- intersectVariables,
- isTemplateVariablesEnabled,
-} from '@/react/portainer/custom-templates/components/utils';
+import { isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
import { AccessControlForm } from '@/react/portainer/access-control';
import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -24,6 +20,7 @@ import { FormActions } from '@@/form-components/FormActions';
import { FormSection } from '@@/form-components/FormSection';
import { EdgeTemplateSettings } from '../types';
+import { useParseTemplateOnFileChange } from '../useParseTemplateOnFileChange';
import { EdgeSettingsFieldset } from './EdgeSettingsFieldset';
import { FormValues, Method, initialBuildMethods } from './types';
@@ -57,6 +54,10 @@ export function InnerForm({
isEditor && !isSubmitting && !isLoading
);
+ const handleChangeFileContent = useParseTemplateOnFileChange(
+ values.Variables
+ );
+
const texts = textByType[values.Type];
return (
@@ -181,36 +182,6 @@ export function InnerForm({
);
- function handleChangeFileContent(value: string) {
- setFieldValue(
- 'FileContent',
- value,
- isTemplateVariablesEnabled ? !value : true
- );
- parseTemplate(value);
- }
-
- function parseTemplate(value: string) {
- if (!isTemplateVariablesEnabled || value === '') {
- setFieldValue('Variables', []);
- return;
- }
-
- const [variables, validationError] = getTemplateVariables(value);
- const isValid = !!variables;
-
- setFieldError(
- 'FileContent',
- validationError ? `Template invalid: ${validationError}` : undefined
- );
- if (isValid) {
- setFieldValue(
- 'Variables',
- intersectVariables(values.Variables, variables)
- );
- }
- }
-
function handleChangeMethod(method: Method) {
setFieldValue('FileContent', '');
setFieldValue('Variables', []);
diff --git a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx
index d169bf87e..479f2c101 100644
--- a/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx
+++ b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx
@@ -20,7 +20,11 @@ import {
import { initialBuildMethods } from './types';
-export function useValidation(isEdge: boolean) {
+export function useValidation({
+ viewType,
+}: {
+ viewType: 'kube' | 'docker' | 'edge';
+}) {
const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates();
@@ -52,10 +56,13 @@ export function useValidation(isEdge: boolean) {
then: () => buildGitValidationSchema(gitCredentialsQuery.data || []),
}),
Variables: variablesValidation(),
- EdgeSettings: isEdge ? edgeFieldsetValidation() : mixed(),
+ EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
}).concat(
- commonFieldsValidation({ templates: customTemplatesQuery.data })
+ commonFieldsValidation({
+ templates: customTemplatesQuery.data,
+ viewType,
+ })
),
- [customTemplatesQuery.data, gitCredentialsQuery.data, isEdge]
+ [customTemplatesQuery.data, gitCredentialsQuery.data, viewType]
);
}
diff --git a/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx b/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx
new file mode 100644
index 000000000..c033f9e25
--- /dev/null
+++ b/app/react/portainer/templates/custom-templates/EditView/EditForm.tsx
@@ -0,0 +1,113 @@
+import { Formik } from 'formik';
+import { useRouter } from '@uirouter/react';
+
+import { notifySuccess } from '@/portainer/services/notifications';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { isKubernetesEnvironment } from '@/react/portainer/environments/utils';
+
+import { CustomTemplate } from '../types';
+import { useUpdateTemplateMutation } from '../queries/useUpdateTemplateMutation';
+import { useCustomTemplateFile } from '../queries/useCustomTemplateFile';
+import { TemplateViewType } from '../useViewType';
+
+import { useInitialValues } from './useInitialValues';
+import { FormValues } from './types';
+import { useValidation } from './useValidation';
+import { InnerForm } from './InnerForm';
+
+export function EditForm({
+ template,
+ environmentId,
+ viewType,
+}: {
+ template: CustomTemplate;
+ environmentId?: EnvironmentId;
+ viewType: TemplateViewType;
+}) {
+ const isEdge = template.EdgeTemplate;
+ const isGit = !!template.GitConfig;
+
+ const router = useRouter();
+ const disableEditor = useDisableEditor(isGit);
+ const mutation = useUpdateTemplateMutation();
+ const validation = useValidation({
+ viewType,
+ isGit,
+ templateId: template.Id,
+ });
+
+ const fileContentQuery = useCustomTemplateFile(template.Id);
+
+ const initialValues = useInitialValues({
+ isEdge,
+ template,
+ templateFile: fileContentQuery.data,
+ });
+
+ if (fileContentQuery.isLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+
+ function handleSubmit(values: FormValues) {
+ mutation.mutate(
+ {
+ id: template.Id,
+ EdgeTemplate: template.EdgeTemplate,
+ Description: values.Description,
+ Title: values.Title,
+ Type: values.Type,
+ Logo: values.Logo,
+ FileContent: values.FileContent,
+ Note: values.Note,
+ Platform: values.Platform,
+ Variables: values.Variables,
+ EdgeSettings: values.EdgeSettings,
+ AccessControl: values.AccessControl,
+ resourceControlId: template.ResourceControl?.Id,
+ ...values.Git,
+ },
+ {
+ onSuccess() {
+ notifySuccess('Success', 'Template updated successfully');
+ router.stateService.go('^');
+ },
+ }
+ );
+ }
+}
+
+function useDisableEditor(isGit: boolean) {
+ const environment = useCurrentEnvironment(false);
+
+ const deploymentOptionsQuery = useEnvironmentDeploymentOptions(
+ environment.data && isKubernetesEnvironment(environment.data.Type)
+ ? environment.data.Id
+ : undefined
+ );
+
+ return isGit || !!deploymentOptionsQuery.data?.hideAddWithForm;
+}
diff --git a/app/react/portainer/templates/custom-templates/EditView/EditView.tsx b/app/react/portainer/templates/custom-templates/EditView/EditView.tsx
new file mode 100644
index 000000000..3927b9114
--- /dev/null
+++ b/app/react/portainer/templates/custom-templates/EditView/EditView.tsx
@@ -0,0 +1,49 @@
+import { useCurrentStateAndParams } from '@uirouter/react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { PageHeader } from '@@/PageHeader';
+import { Widget } from '@@/Widget';
+
+import { useCustomTemplate } from '../queries/useCustomTemplate';
+import { useViewType } from '../useViewType';
+
+import { EditForm } from './EditForm';
+
+export function EditView() {
+ const viewType = useViewType();
+ const {
+ params: { id },
+ } = useCurrentStateAndParams();
+ const environmentId = useEnvironmentId(false);
+ const templateQuery = useCustomTemplate(id);
+
+ if (!templateQuery.data) {
+ return null;
+ }
+
+ const template = templateQuery.data;
+
+ return (
+
+ );
+}
diff --git a/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx
similarity index 73%
rename from app/react/edge/templates/custom-templates/EditView/InnerForm.tsx
rename to app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx
index 6748c8a4e..a5f5d0bb2 100644
--- a/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx
+++ b/app/react/portainer/templates/custom-templates/EditView/InnerForm.tsx
@@ -5,31 +5,36 @@ import { CommonFields } from '@/react/portainer/custom-templates/components/Comm
import { CustomTemplatesVariablesDefinitionField } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
import { PlatformField } from '@/react/portainer/custom-templates/components/PlatformSelector';
import { GitForm } from '@/react/portainer/gitops/GitForm';
-import {
- getTemplateVariables,
- intersectVariables,
- isTemplateVariablesEnabled,
-} from '@/react/portainer/custom-templates/components/utils';
+import { isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
import { EdgeSettingsFieldset } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset';
+import { StackType } from '@/react/common/stacks/types';
+import { textByType } from '@/react/common/stacks/common/form-texts';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { AccessControlForm } from '@/react/portainer/access-control';
+import { AccessControlFormData } from '@/react/portainer/access-control/types';
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
import { FormActions } from '@@/form-components/FormActions';
import { Button } from '@@/buttons';
import { FormError } from '@@/form-components/FormError';
+import { useParseTemplateOnFileChange } from '../useParseTemplateOnFileChange';
+
import { FormValues } from './types';
export function InnerForm({
isLoading,
+ environmentId,
isEditorReadonly,
gitFileContent,
gitFileError,
refreshGitFile,
}: {
isLoading: boolean;
+ environmentId?: EnvironmentId;
isEditorReadonly: boolean;
gitFileContent?: string;
gitFileError?: string;
@@ -42,9 +47,9 @@ export function InnerForm({
errors,
isValid,
setFieldError,
+ setValues,
isSubmitting,
dirty,
- setValues,
} = useFormikContext();
usePreventExit(
@@ -52,6 +57,13 @@ export function InnerForm({
values.FileContent,
!isEditorReadonly && !isSubmitting && !isLoading
);
+
+ const handleChangeFileContent = useParseTemplateOnFileChange(
+ values.Variables
+ );
+
+ const texts = textByType[values.Type];
+
return (