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

- - Custom template could not be loaded, {{ $ctrl.state.templatePreviewError }}.

-
-
- - - -

Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)

-

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

-
-
- - - - - -
Actions
-
-
- - - {{ $ctrl.state.formValidationError }} - -
-
-
-
-
-
-
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 @@ - - -
-
- - -
- - - - - - - - -
-
-
-
-

- - Custom template could not be loaded, {{ $ctrl.state.templatePreviewError }}.

-
-
- - - - -

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

-
-
- - -
-
-
- - Template is invalid. -
-
-
- - - - - -
Actions
-
-
- - - {{ $ctrl.state.formValidationError }} - -
-
-
-
-
-
-
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 (
- setFieldValue('Platform', value)} - /> + {values.Type !== StackType.Kubernetes && ( + <> + setFieldValue('Platform', value)} + /> - setFieldValue('Type', value)} - /> + setFieldValue('Type', value)} + /> + + )} -

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

+ {texts.editor.description}
- {isTemplateVariablesEnabled && ( - setFieldValue('Variables', values)} - isVariablesNamesFromParent={!isEditorReadonly} - errors={errors.Variables} - /> - )} - {values.Git && ( <>
@@ -137,6 +137,25 @@ export function InnerForm({ )} + {isTemplateVariablesEnabled && ( + setFieldValue('Variables', values)} + isVariablesNamesFromParent={!isEditorReadonly} + errors={errors.Variables} + /> + )} + + {!!values.AccessControl && ( + setFieldValue('AccessControl', values)} + values={values.AccessControl} + errors={errors.AccessControl as FormikErrors} + formNamespace="accessControl" + /> + )} + {values.EdgeSettings && ( @@ -163,33 +182,4 @@ 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); - - setFieldError( - 'FileContent', - validationError ? `Template invalid: ${validationError}` : undefined - ); - if (variables) { - setFieldValue( - 'Variables', - intersectVariables(values.Variables, variables) - ); - } - } } diff --git a/app/react/edge/templates/custom-templates/EditView/index.ts b/app/react/portainer/templates/custom-templates/EditView/index.ts similarity index 100% rename from app/react/edge/templates/custom-templates/EditView/index.ts rename to app/react/portainer/templates/custom-templates/EditView/index.ts diff --git a/app/react/edge/templates/custom-templates/EditView/types.ts b/app/react/portainer/templates/custom-templates/EditView/types.ts similarity index 78% rename from app/react/edge/templates/custom-templates/EditView/types.ts rename to app/react/portainer/templates/custom-templates/EditView/types.ts index d9c257e1d..bd911f666 100644 --- a/app/react/edge/templates/custom-templates/EditView/types.ts +++ b/app/react/portainer/templates/custom-templates/EditView/types.ts @@ -1,9 +1,11 @@ import { StackType } from '@/react/common/stacks/types'; +import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields'; import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { Platform } from '@/react/portainer/templates/types'; -import { type Values as CommonFieldsValues } from '@/react/portainer/custom-templates/components/CommonFields'; import { GitFormModel } from '@/react/portainer/gitops/types'; -import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; + +import { EdgeTemplateSettings } from '../types'; export interface FormValues extends CommonFieldsValues { Platform: Platform; @@ -11,5 +13,6 @@ export interface FormValues extends CommonFieldsValues { FileContent: string; Git?: GitFormModel; Variables: DefinitionFieldValues; + AccessControl?: AccessControlFormData; EdgeSettings?: EdgeTemplateSettings; } diff --git a/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts b/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts new file mode 100644 index 000000000..a2b9b8a3f --- /dev/null +++ b/app/react/portainer/templates/custom-templates/EditView/useInitialValues.ts @@ -0,0 +1,41 @@ +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { toGitFormModel } from '@/react/portainer/gitops/types'; +import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; + +import { CustomTemplate } from '../types'; + +import { FormValues } from './types'; + +export function useInitialValues({ + template, + templateFile, + isEdge, +}: { + template: CustomTemplate; + templateFile: string | undefined; + isEdge: boolean; +}): FormValues { + const { user, isAdmin } = useCurrentUser(); + + return { + Title: template.Title, + FileContent: templateFile || '', + Type: template.Type, + Platform: template.Platform, + Description: template.Description, + Note: template.Note, + Logo: template.Logo, + Variables: template.Variables, + Git: template.GitConfig ? toGitFormModel(template.GitConfig) : undefined, + AccessControl: + !isEdge && template.ResourceControl + ? parseAccessControlFormData( + isAdmin, + user.Id, + new ResourceControlViewModel(template.ResourceControl) + ) + : undefined, + EdgeSettings: template.EdgeSettings, + }; +} diff --git a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx similarity index 78% rename from app/react/edge/templates/custom-templates/EditView/useValidation.tsx rename to app/react/portainer/templates/custom-templates/EditView/useValidation.tsx index b7e871244..a4df5b3ae 100644 --- a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/EditView/useValidation.tsx @@ -3,19 +3,26 @@ import { useMemo } from 'react'; import { StackType } from '@/react/common/stacks/types'; import { validation as commonFieldsValidation } from '@/react/portainer/custom-templates/components/CommonFields'; -import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { Platform } from '@/react/portainer/templates/types'; import { variablesValidation } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm'; import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service'; import { useCurrentUser } from '@/react/hooks/useUser'; import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates'; -import { Platform } from '@/react/portainer/templates/types'; import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation'; -export function useValidation( - currentTemplateId: CustomTemplate['Id'], - isGit: boolean -) { +import { CustomTemplate } from '../types'; +import { TemplateViewType } from '../useViewType'; + +export function useValidation({ + isGit, + templateId, + viewType, +}: { + isGit: boolean; + templateId: CustomTemplate['Id']; + viewType: TemplateViewType; +}) { const { user } = useCurrentUser(); const gitCredentialsQuery = useGitCredentials(user.Id); const customTemplatesQuery = useCustomTemplates(); @@ -33,26 +40,26 @@ export function useValidation( StackType.Kubernetes, ]) .default(StackType.DockerCompose), - FileContent: isGit - ? string().default('') - : string().required('Template is required.'), + FileContent: string().required('Template is required.'), Git: isGit ? buildGitValidationSchema(gitCredentialsQuery.data || []) : mixed(), Variables: variablesValidation(), - EdgeSettings: edgeFieldsetValidation(), + EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(), }).concat( commonFieldsValidation({ templates: customTemplatesQuery.data, - currentTemplateId, + currentTemplateId: templateId, + viewType, }) ), [ - currentTemplateId, customTemplatesQuery.data, gitCredentialsQuery.data, isGit, + templateId, + viewType, ] ); } diff --git a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts index 3295ff803..f3fc06118 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts @@ -42,7 +42,6 @@ interface CreateTemplatePayload { Description: string; Note: string; Logo: string; - AccessControl?: AccessControlFormData; } export function useCreateTemplateMutation() { @@ -50,7 +49,9 @@ export function useCreateTemplateMutation() { const queryClient = useQueryClient(); return useMutation( - async (payload: CreateTemplatePayload) => { + async ( + payload: CreateTemplatePayload & { AccessControl?: AccessControlFormData } + ) => { const template = await createTemplate(user.Id, payload); const resourceControl = template.ResourceControl; diff --git a/app/react/portainer/templates/custom-templates/queries/useCustomTemplateFile.ts b/app/react/portainer/templates/custom-templates/queries/useCustomTemplateFile.ts index 9f709fe84..021c2c4c7 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCustomTemplateFile.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCustomTemplateFile.ts @@ -19,6 +19,9 @@ export function useCustomTemplateFile(id?: CustomTemplate['Id'], git = false) { { ...withGlobalError('Failed to get custom template file'), enabled: !!id, + // there's nothing to do with a new file content, so we're disabling refetch + refetchOnReconnect: false, + refetchOnWindowFocus: false, } ); } diff --git a/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts index 8ba62774a..02367490a 100644 --- a/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useUpdateTemplateMutation.ts @@ -8,6 +8,8 @@ import { } from '@/react-tools/react-query'; import { StackType } from '@/react/common/stacks/types'; import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; import { CustomTemplate, EdgeTemplateSettings } from '../types'; import { Platform } from '../../types'; @@ -18,7 +20,21 @@ export function useUpdateTemplateMutation() { const queryClient = useQueryClient(); return useMutation( - updateTemplate, + async ( + payload: CustomTemplateUpdatePayload & { + AccessControl?: AccessControlFormData; + resourceControlId?: number; + } + ) => { + await updateTemplate(payload); + + if (payload.resourceControlId && payload.AccessControl) { + await applyResourceControl( + payload.AccessControl, + payload.resourceControlId + ); + } + }, mutationOptions( withInvalidate(queryClient, [['custom-templates']]), withGlobalError('Failed to update template') @@ -30,6 +46,7 @@ export function useUpdateTemplateMutation() { * Payload for updating a custom template */ interface CustomTemplateUpdatePayload { + id: CustomTemplate['Id']; /** URL of the template's logo */ Logo?: string; /** Title of the template */ @@ -78,9 +95,7 @@ interface CustomTemplateUpdatePayload { EdgeSettings?: EdgeTemplateSettings; } -async function updateTemplate( - values: CustomTemplateUpdatePayload & { id: CustomTemplate['Id'] } -) { +async function updateTemplate(values: CustomTemplateUpdatePayload) { try { const { data } = await axios.put( buildUrl({ id: values.id }), diff --git a/app/react/portainer/templates/custom-templates/useParseTemplateOnFileChange.tsx b/app/react/portainer/templates/custom-templates/useParseTemplateOnFileChange.tsx new file mode 100644 index 000000000..b20d17a96 --- /dev/null +++ b/app/react/portainer/templates/custom-templates/useParseTemplateOnFileChange.tsx @@ -0,0 +1,43 @@ +import { useFormikContext } from 'formik'; + +import { VariableDefinition } from '../../custom-templates/components/CustomTemplatesVariablesDefinitionField'; +import { + getTemplateVariables, + intersectVariables, + isTemplateVariablesEnabled, +} from '../../custom-templates/components/utils'; + +export function useParseTemplateOnFileChange( + oldVariables: VariableDefinition[] +) { + const { setFieldValue, setFieldError } = useFormikContext(); + + return handleChangeFileContent; + + 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(oldVariables, variables)); + } + } +} diff --git a/app/react/portainer/templates/custom-templates/useViewType.ts b/app/react/portainer/templates/custom-templates/useViewType.ts new file mode 100644 index 000000000..cb6419c2a --- /dev/null +++ b/app/react/portainer/templates/custom-templates/useViewType.ts @@ -0,0 +1,18 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +export type TemplateViewType = 'kube' | 'docker' | 'edge'; + +export function useViewType(): TemplateViewType { + const { + state: { name }, + } = useCurrentStateAndParams(); + if (name?.includes('kubernetes')) { + return 'kube'; + } + + if (name?.includes('docker')) { + return 'docker'; + } + + return 'edge'; +}