diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index 024ca1189..798843c82 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -103,6 +103,8 @@ type customTemplateFromFileContentPayload struct { FileContent string `validate:"required"` // Definitions of variables in the stack file Variables []portainer.CustomTemplateVariableDefinition + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` } func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) error { @@ -160,15 +162,16 @@ func (handler *Handler) createCustomTemplateFromFileContent(r *http.Request) (*p customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() customTemplate := &portainer.CustomTemplate{ - ID: portainer.CustomTemplateID(customTemplateID), - Title: payload.Title, - EntryPoint: filesystem.ComposeFileDefaultName, - Description: payload.Description, - Note: payload.Note, - Platform: (payload.Platform), - Type: (payload.Type), - Logo: payload.Logo, - Variables: payload.Variables, + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + EntryPoint: filesystem.ComposeFileDefaultName, + Description: payload.Description, + Note: payload.Note, + Platform: (payload.Platform), + Type: (payload.Type), + Logo: payload.Logo, + Variables: payload.Variables, + EdgeTemplate: payload.EdgeTemplate, } templateFolder := strconv.Itoa(customTemplateID) @@ -218,6 +221,8 @@ type customTemplateFromGitRepositoryPayload struct { TLSSkipVerify bool `example:"false"` // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` } func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) error { @@ -264,7 +269,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) // @success 200 {object} portainer.CustomTemplate // @failure 400 "Invalid request" // @failure 500 "Server error" -// @router /custom_templates/repository [post] +// @router /custom_templates/create/repository [post] func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (*portainer.CustomTemplate, error) { var payload customTemplateFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) @@ -283,6 +288,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( Logo: payload.Logo, Variables: payload.Variables, IsComposeFormat: payload.IsComposeFormat, + EdgeTemplate: payload.EdgeTemplate, } getProjectPath := func() string { @@ -367,6 +373,8 @@ type customTemplateFromFileUploadPayload struct { FileContent []byte // Definitions of variables in the stack file Variables []portainer.CustomTemplateVariableDefinition + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` } func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) error { @@ -419,8 +427,15 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er if err != nil { return errors.New("Invalid variables. Ensure that the variables are valid JSON") } - return validateVariablesDefinitions(payload.Variables) + err = validateVariablesDefinitions(payload.Variables) + if err != nil { + return err + } } + + edgeTemplate, _ := request.RetrieveBooleanMultiPartFormValue(r, "EdgeTemplate", true) + payload.EdgeTemplate = edgeTemplate + return nil } @@ -444,7 +459,7 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er // @success 200 {object} portainer.CustomTemplate // @failure 400 "Invalid request" // @failure 500 "Server error" -// @router /custom_templates/file [post] +// @router /custom_templates/create/file [post] func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*portainer.CustomTemplate, error) { payload := &customTemplateFromFileUploadPayload{} err := payload.Validate(r) @@ -454,15 +469,16 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po customTemplateID := handler.DataStore.CustomTemplate().GetNextIdentifier() customTemplate := &portainer.CustomTemplate{ - ID: portainer.CustomTemplateID(customTemplateID), - Title: payload.Title, - Description: payload.Description, - Note: payload.Note, - Platform: payload.Platform, - Type: payload.Type, - Logo: payload.Logo, - EntryPoint: filesystem.ComposeFileDefaultName, - Variables: payload.Variables, + ID: portainer.CustomTemplateID(customTemplateID), + Title: payload.Title, + Description: payload.Description, + Note: payload.Note, + Platform: payload.Platform, + Type: payload.Type, + Logo: payload.Logo, + EntryPoint: filesystem.ComposeFileDefaultName, + Variables: payload.Variables, + EdgeTemplate: payload.EdgeTemplate, } templateFolder := strconv.Itoa(customTemplateID) diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index 221bafa7c..91f09cfd4 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -59,6 +59,8 @@ type customTemplateUpdatePayload struct { TLSSkipVerify bool `example:"false"` // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` } func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { @@ -161,6 +163,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ customTemplate.Type = payload.Type customTemplate.Variables = payload.Variables customTemplate.IsComposeFormat = payload.IsComposeFormat + customTemplate.EdgeTemplate = payload.EdgeTemplate if payload.RepositoryURL != "" { if !govalidator.IsURL(payload.RepositoryURL) { diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go index 4a988e557..5b2c7254d 100644 --- a/api/http/handler/edgetemplates/edgetemplate_list.go +++ b/api/http/handler/edgetemplates/edgetemplate_list.go @@ -18,6 +18,7 @@ type templateFileFormat struct { } // @id EdgeTemplateList +// @deprecated // @summary Fetches the list of Edge Templates // @description **Access policy**: administrator // @tags edge_templates diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go index bf08aa400..d6c98553f 100644 --- a/api/http/handler/edgetemplates/handler.go +++ b/api/http/handler/edgetemplates/handler.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -25,7 +26,7 @@ func NewHandler(bouncer security.BouncerService) *Handler { } h.Handle("/edge_templates", - bouncer.AdminAccess(httperror.LoggerHandler(h.edgeTemplateList))).Methods(http.MethodGet) + bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet) return h } diff --git a/api/http/middlewares/deprecated.go b/api/http/middlewares/deprecated.go index 8a510e079..5158db2d7 100644 --- a/api/http/middlewares/deprecated.go +++ b/api/http/middlewares/deprecated.go @@ -16,6 +16,12 @@ func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *h return } + if newUrl == "" { + log.Warn().Msg("This api is deprecated.") + router.ServeHTTP(w, r) + return + } + log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl) redirectedRequest := r.Clone(r.Context()) diff --git a/api/portainer.go b/api/portainer.go index 23fe238e2..027a572cf 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -187,6 +187,8 @@ type ( GitConfig *gittypes.RepoConfig `json:"GitConfig"` // IsComposeFormat indicates if the Kubernetes template is created from a Docker Compose file IsComposeFormat bool `example:"false"` + // EdgeTemplate indicates if this template purpose for Edge Stack + EdgeTemplate bool `example:"false"` } // CustomTemplateID represents a custom template identifier diff --git a/app/edge/__module.js b/app/edge/__module.js index 1b0393e50..27b182403 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -62,7 +62,7 @@ angular const stacksNew = { name: 'edge.stacks.new', - url: '/new', + url: '/new?templateId', views: { 'content@': { component: 'createEdgeStackView', @@ -150,6 +150,44 @@ angular component: 'edgeAppTemplatesView', }, }, + data: { + docs: '/user/edge/templates', + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom', + url: '/custom', + views: { + 'content@': { + component: 'edgeCustomTemplatesView', + }, + }, + data: { + docs: '/user/edge/templates/custom', + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom.new', + url: '/new?appTemplateId&type', + + views: { + 'content@': { + component: 'edgeCreateCustomTemplatesView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'edge.templates.custom.edit', + url: '/:templateId', + + views: { + 'content@': { + component: 'edgeEditCustomTemplatesView', + }, + }, }); $stateRegistryProvider.register(edge); diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index e2c67c309..e7a1f8b92 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -13,6 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter'; import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable'; +import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset'; export const componentsModule = angular .module('portainer.edge.react.components', []) @@ -99,4 +100,8 @@ export const componentsModule = angular 'onChange', 'value', ]) + ) + .component( + 'edgeStackCreateTemplateFieldset', + r2a(withReactQuery(TemplateFieldset), ['onChange', 'value', 'onChangeFile']) ).name; diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts index d19233b35..c2b1caedc 100644 --- a/app/edge/react/views/templates.ts +++ b/app/edge/react/views/templates.ts @@ -3,12 +3,26 @@ import angular from 'angular'; 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 { CreateView } from '@/react/edge/templates/custom-templates/CreateView'; +import { EditView } from '@/react/edge/templates/custom-templates/EditView'; import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView'; export const templatesModule = angular .module('portainer.app.react.components.templates', []) - .component( 'edgeAppTemplatesView', r2a(withCurrentUser(withUIRouter(AppTemplatesView)), []) + ) + .component( + 'edgeCustomTemplatesView', + r2a(withCurrentUser(withUIRouter(ListView)), []) + ) + .component( + 'edgeCreateCustomTemplatesView', + r2a(withCurrentUser(withUIRouter(CreateView)), []) + ) + .component( + 'edgeEditCustomTemplatesView', + r2a(withCurrentUser(withUIRouter(EditView)), []) ).name; diff --git a/app/edge/rest/edge-templates.js b/app/edge/rest/edge-templates.js deleted file mode 100644 index e5aa85e2f..000000000 --- a/app/edge/rest/edge-templates.js +++ /dev/null @@ -1,11 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.edge').factory('EdgeTemplates', function EdgeStacksFactory($resource, API_ENDPOINT_EDGE_TEMPLATES) { - return $resource( - API_ENDPOINT_EDGE_TEMPLATES, - {}, - { - query: { method: 'GET', isArray: true }, - } - ); -}); diff --git a/app/edge/services/edge-template.js b/app/edge/services/edge-template.js deleted file mode 100644 index c8ce4a5ab..000000000 --- a/app/edge/services/edge-template.js +++ /dev/null @@ -1,23 +0,0 @@ -import angular from 'angular'; - -class EdgeTemplateService { - /* @ngInject */ - constructor(EdgeTemplates) { - this.EdgeTemplates = EdgeTemplates; - } - - edgeTemplates() { - return this.EdgeTemplates.query().$promise; - } - - async edgeTemplate(template) { - const response = await fetch(template.stackFile); - if (!response.ok) { - throw new Error(response.statusText); - } - - return response.text(); - } -} - -angular.module('portainer.edge').service('EdgeTemplateService', EdgeTemplateService); diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js index f17755926..2116bf346 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js @@ -5,11 +5,14 @@ import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; import { EnvironmentType } from '@/react/portainer/environments/types'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { getCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate'; +import { notifyError } from '@/portainer/services/notifications'; +import { getCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; export default class CreateEdgeStackViewController { /* @ngInject */ - constructor($state, $window, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async, $scope) { - Object.assign(this, { $state, $window, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async, $scope }); + constructor($state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope) { + Object.assign(this, { $state, $window, EdgeStackService, EdgeGroupService, Notifications, FormHelper, $async, $scope }); this.formValues = { Name: '', @@ -41,6 +44,8 @@ export default class CreateEdgeStackViewController { hasKubeEndpoint: false, endpointTypes: [], baseWebhookUrl: baseEdgeStackWebhookUrl(), + isEdit: false, + selectedTemplate: null, }; this.edgeGroups = null; @@ -57,6 +62,16 @@ export default class CreateEdgeStackViewController { this.hasType = this.hasType.bind(this); this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); this.onEnvVarChange = this.onEnvVarChange.bind(this); + this.onChangeTemplate = this.onChangeTemplate.bind(this); + } + + /** + * @param {import('@/react/portainer/templates/custom-templates/types').CustomTemplate} template + */ + onChangeTemplate(template) { + return this.$scope.$evalAsync(() => { + this.state.selectedTemplate = template; + }); } onEnvVarChange(envVars) { @@ -70,7 +85,7 @@ export default class CreateEdgeStackViewController { const metadata = { type: methodLabel(this.state.Method), format }; if (metadata.type === 'template') { - metadata.templateName = this.selectedTemplate.title; + metadata.templateName = this.state.selectedTemplate && this.state.selectedTemplate.title; } return { metadata }; @@ -95,6 +110,18 @@ export default class CreateEdgeStackViewController { } } + async preSelectTemplate(templateId) { + try { + this.state.Method = 'template'; + const template = await getCustomTemplate(templateId); + this.onChangeTemplate(template); + const fileContent = await getCustomTemplateFile({ id: templateId, git: !!template.GitConfig }); + this.formValues.StackFileContent = fileContent; + } catch (e) { + notifyError('Failed loading template', e); + } + } + async $onInit() { try { this.edgeGroups = await this.EdgeGroupService.groups(); @@ -102,6 +129,11 @@ export default class CreateEdgeStackViewController { this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups'); } + const templateId = this.$state.params.templateId; + if (templateId) { + this.preSelectTemplate(templateId); + } + this.$window.onbeforeunload = () => { if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) { return ''; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index c4990c9da..a2bf5914e 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -57,6 +57,8 @@ ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose" form-values="$ctrl.formValues" state="$ctrl.state" + template="$ctrl.state.selectedTemplate" + on-change-template="($ctrl.onChangeTemplate)" > { - this.formValues.StackFileContent = ''; - try { - const fileContent = await fetchFilePreview(template.id); - this.formValues.StackFileContent = fileContent; - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve Template'); - } - }); } onChangeFileContent(value) { - this.formValues.StackFileContent = value; - this.state.isEditorDirty = true; + return this.$async(async () => { + this.formValues.StackFileContent = value; + this.state.isEditorDirty = true; + }); } onChangeFile(value) { @@ -54,17 +39,6 @@ class DockerComposeFormController { this.formValues.StackFile = value; }); } - - async $onInit() { - return this.$async(async () => { - try { - const templates = await this.EdgeTemplateService.edgeTemplates(); - this.templates = templates.map((template) => ({ ...template, label: `${template.title} - ${template.description}` })); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve Templates'); - } - }); - } } export default DockerComposeFormController; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html index 328a6ec28..b1af39eee 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html @@ -1,14 +1,25 @@
Build method
+ +
+ +
+ + You can get more information about Compose file format in the @@ -21,51 +32,12 @@ You can upload a Compose file from your computer. - - - -
-
- -
- -
-
- -
-
Information
-
-
-
-
-
-
- - - - - - +
+
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js index 59fd0aecc..21a4ab7b2 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js @@ -7,5 +7,7 @@ export const edgeStacksDockerComposeForm = { bindings: { formValues: '=', state: '=', + template: '<', + onChangeTemplate: '<', }, }; diff --git a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js index 2535d1ae3..92d58f218 100644 --- a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js +++ b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js @@ -1,6 +1,5 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; -import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils'; import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; @@ -13,7 +12,7 @@ class KubeCreateCustomTemplateViewController { this.methodOptions = [editor, upload, git]; this.templates = null; - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.state = { method: 'editor', @@ -83,7 +82,7 @@ class KubeCreateCustomTemplateViewController { return; } - const variables = getTemplateVariables(templateStr); + const [variables] = getTemplateVariables(templateStr); const isValid = !!variables; diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js index 58979eb3f..9580855f8 100644 --- a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js +++ b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js @@ -24,8 +24,8 @@ export default class KubeCustomTemplatesViewController { this.selectTemplate = this.selectTemplate.bind(this); } - selectTemplate(template) { - this.$state.go('kubernetes.deploy', { templateId: template }); + selectTemplate(templateId) { + this.$state.go('kubernetes.deploy', { templateId }); } isEditAllowed(template) { @@ -36,7 +36,8 @@ export default class KubeCustomTemplatesViewController { getTemplates() { return this.$async(async () => { try { - this.templates = await this.CustomTemplateService.customTemplates(3); + const templates = await this.CustomTemplateService.customTemplates(3); + this.templates = templates.filter((t) => !t.EdgeTemplate); } catch (err) { this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates'); } 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 index 100e16dc5..3d74acb4b 100644 --- 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 @@ -1,7 +1,6 @@ import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; -import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; +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'; @@ -11,7 +10,7 @@ class KubeEditCustomTemplateViewController { constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.formValues = { Variables: [], @@ -112,7 +111,7 @@ class KubeEditCustomTemplateViewController { return; } - const variables = getTemplateVariables(templateStr); + const [variables] = getTemplateVariables(templateStr); const isValid = !!variables; diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 5259c2d89..382b399b8 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -4,14 +4,14 @@ import stripAnsi from 'strip-ansi'; import PortainerError from '@/portainer/error'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; -import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; +import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; class KubernetesDeployController { /* @ngInject */ @@ -25,7 +25,7 @@ class KubernetesDeployController { this.StackService = StackService; this.CustomTemplateService = CustomTemplateService; - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.deployOptions = [{ ...kubernetes, value: KubernetesDeployManifestTypes.KUBERNETES }]; @@ -72,7 +72,7 @@ class KubernetesDeployController { RepositoryPassword: '', AdditionalFiles: [], ComposeFilePathInRepository: '', - Variables: {}, + Variables: [], AutoUpdate: parseAutoUpdateResponse(), TLSSkipVerify: false, Name: '', @@ -220,7 +220,7 @@ class KubernetesDeployController { } if (template.Variables && template.Variables.length > 0) { - const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); + const variables = getVariablesFieldDefaultValues(template.Variables); this.onChangeTemplateVariables(variables); } } catch (err) { diff --git a/app/portainer/react/components/custom-templates/index.ts b/app/portainer/react/components/custom-templates/index.ts index 9823bad0f..99d60faba 100644 --- a/app/portainer/react/components/custom-templates/index.ts +++ b/app/portainer/react/components/custom-templates/index.ts @@ -26,6 +26,7 @@ export const ngModule = angular 'value', 'onChange', 'definitions', + 'errors', ]) ) .component('customTemplatesVariablesField', VariablesFieldAngular) @@ -46,7 +47,6 @@ export const ngModule = angular 'selectedId', 'disabledTypes', 'fixedCategories', - 'hideDuplicate', ]) ) .component( @@ -56,6 +56,7 @@ export const ngModule = angular 'onSelect', 'templates', 'selectedId', + 'templateLinkParams', ]) ) .component( diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index ae4ac3696..d2648ade2 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -101,3 +101,30 @@ export function isAxiosError< >(error: unknown): error is AxiosError { return axiosOrigin.isAxiosError(error); } + +export function arrayToJson(arr?: Array) { + if (!arr) { + return ''; + } + + return JSON.stringify(arr); +} + +export function json2formData(json: Record) { + const formData = new FormData(); + + Object.entries(json).forEach(([key, value]) => { + if (typeof value === 'undefined' || value === null) { + return; + } + + if (Array.isArray(value)) { + formData.append(key, arrayToJson(value)); + return; + } + + formData.append(key, value as string); + }); + + return formData; +} diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js index 3dc557db4..c1b170f5d 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js @@ -1,11 +1,10 @@ import _ from 'lodash'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; -import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils'; import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; -import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateInfoMutation'; +import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile'; class CreateCustomTemplateViewController { /* @ngInject */ @@ -26,7 +25,7 @@ class CreateCustomTemplateViewController { this.buildMethods = [editor, upload, git]; - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.formValues = { Title: '', @@ -207,7 +206,7 @@ class CreateCustomTemplateViewController { return; } - const variables = getTemplateVariables(templateStr); + const [variables] = getTemplateVariables(templateStr); const isValid = !!variables; 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 6a6ece2dc..286591b8b 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 { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { confirmDelete } from '@@/modals/confirm'; +import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; class CustomTemplatesViewController { /* @ngInject */ @@ -34,7 +34,7 @@ class CustomTemplatesViewController { this.StateManager = StateManager; this.StackService = StackService; - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.DOCKER_STANDALONE = 'DOCKER_STANDALONE'; this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE'; @@ -93,7 +93,8 @@ class CustomTemplatesViewController { } async getTemplatesAsync() { try { - this.templates = await this.CustomTemplateService.customTemplates([1, 2]); + const templates = await this.CustomTemplateService.customTemplates([1, 2]); + this.templates = templates.filter((t) => !t.EdgeTemplate); } catch (err) { this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates'); } @@ -221,7 +222,7 @@ class CustomTemplatesViewController { this.state.deployable = this.isDeployable(applicationState.endpoint, template.Type); if (template.Variables && template.Variables.length > 0) { - const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); + const variables = getVariablesFieldDefaultValues(template.Variables); this.onChangeTemplateVariables(variables); } } 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 index e19731df2..ed9d7b543 100644 --- a/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js +++ b/app/portainer/views/custom-templates/edit-custom-template-view/editCustomTemplateViewController.js @@ -4,8 +4,7 @@ import { ResourceControlViewModel } from '@/react/portainer/access-control/model import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; -import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; class EditCustomTemplateViewController { @@ -13,7 +12,7 @@ class EditCustomTemplateViewController { constructor($async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { Object.assign(this, { $async, $state, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); - this.isTemplateVariablesEnabled = isBE; + this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; this.formValues = { Variables: [], @@ -178,7 +177,7 @@ class EditCustomTemplateViewController { return; } - const variables = getTemplateVariables(templateStr); + const [variables] = getTemplateVariables(templateStr); const isValid = !!variables; diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 797afa525..30ca24938 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -4,12 +4,12 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/ import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; -import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; -import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; +import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { editor, upload, git, customTemplate } from '@@/BoxSelector/common-options/build-methods'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; +import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; angular .module('portainer.app') @@ -28,13 +28,12 @@ angular FormHelper, StackHelper, ContainerHelper, - CustomTemplateService, ContainerService, endpoint ) { $scope.onChangeTemplateId = onChangeTemplateId; $scope.onChangeTemplateVariables = onChangeTemplateVariables; - $scope.isTemplateVariablesEnabled = isBE; + $scope.isTemplateVariablesEnabled = isTemplateVariablesEnabled; $scope.buildAnalyticsProperties = buildAnalyticsProperties; $scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK; $scope.buildMethods = [editor, upload, git, customTemplate]; @@ -54,7 +53,7 @@ angular ComposeFilePathInRepository: 'docker-compose.yml', AccessControlData: new AccessControlFormData(), EnableWebhook: false, - Variables: {}, + Variables: [], AutoUpdate: parseAutoUpdateResponse(), TLSSkipVerify: false, }; @@ -313,7 +312,7 @@ angular } if (template.Variables && template.Variables.length > 0) { - const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, ''])); + const variables = getVariablesFieldDefaultValues(template.Variables); onChangeTemplateVariables(variables); } } catch (err) { diff --git a/app/react/components/Blocklist/BlocklistItem.tsx b/app/react/components/Blocklist/BlocklistItem.tsx index a69b41149..15e9e6d57 100644 --- a/app/react/components/Blocklist/BlocklistItem.tsx +++ b/app/react/components/Blocklist/BlocklistItem.tsx @@ -23,7 +23,7 @@ export function BlocklistItem({ type="button" className={clsx( className, - 'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 text-left', + 'blocklist-item flex items-stretch overflow-hidden bg-transparent w-full !ml-0 no-link text-left', { 'blocklist-item--selected': isSelected, } diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index 9569a04dc..ab8880d05 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -1,4 +1,5 @@ -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, useEffect, useMemo } from 'react'; +import { useTransitionHook } from '@uirouter/react'; import { BROWSER_OS_PLATFORM } from '@/react/constants'; @@ -6,6 +7,10 @@ import { CodeEditor } from '@@/CodeEditor'; import { Tooltip } from '@@/Tip/Tooltip'; import { FormSectionTitle } from './form-components/FormSectionTitle'; +import { FormError } from './form-components/FormError'; +import { confirm } from './modals/confirm'; +import { ModalType } from './modals'; +import { buildConfirmButton } from './modals/utils'; const otherEditorConfig = { tooltip: ( @@ -91,6 +96,8 @@ export function WebEditorForm({
)} + {error && {error}} +
- -
-
{error}
-
); } + +export function usePreventExit( + initialValue: string, + value: string, + check: boolean +) { + const isChanged = useMemo( + () => cleanText(initialValue) !== cleanText(value), + [initialValue, value] + ); + + const preventExit = check && isChanged; + + // when navigating away from the page with unsaved changes, show a portainer prompt to confirm + useTransitionHook('onBefore', {}, async () => { + if (!preventExit) { + return true; + } + const confirmed = await confirm({ + modalType: ModalType.Warn, + title: 'Are you sure?', + message: + 'You currently have unsaved changes in the text editor. Are you sure you want to leave?', + confirmButton: buildConfirmButton('Yes', 'danger'), + }); + return confirmed; + }); + + // when reloading or exiting the page with unsaved changes, show a browser prompt to confirm + useEffect(() => { + function handler(event: BeforeUnloadEvent) { + if (!preventExit) { + return undefined; + } + + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.returnValue = ''; + return ''; + } + + // if the form is changed, then set the onbeforeunload + window.addEventListener('beforeunload', handler); + return () => { + window.removeEventListener('beforeunload', handler); + }; + }, [preventExit]); +} + +function cleanText(value: string) { + return value.replace(/(\r\n|\n|\r)/gm, ''); +} diff --git a/app/react/components/form-components/FormActions.tsx b/app/react/components/form-components/FormActions.tsx index 2e4081d7d..6b804d777 100644 --- a/app/react/components/form-components/FormActions.tsx +++ b/app/react/components/form-components/FormActions.tsx @@ -1,13 +1,16 @@ import { PropsWithChildren } from 'react'; +import { AutomationTestingProps } from '@/types'; + import { LoadingButton } from '@@/buttons'; -interface Props { +import { FormSection } from './FormSection'; + +interface Props extends AutomationTestingProps { submitLabel: string; loadingText: string; isLoading: boolean; isValid: boolean; - 'data-cy'?: string; } export function FormActions({ @@ -19,20 +22,22 @@ export function FormActions({ 'data-cy': dataCy, }: PropsWithChildren) { return ( -
-
- - {submitLabel} - + +
+
+ + {submitLabel} + - {children} + {children} +
-
+ ); } diff --git a/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx new file mode 100644 index 000000000..b49c52713 --- /dev/null +++ b/app/react/edge/edge-stacks/CreateView/TemplateFieldset.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import sanitize from 'sanitize-html'; + +import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates'; +import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { useCustomTemplateFileMutation } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile'; +import { + CustomTemplatesVariablesField, + VariablesFieldValue, + getVariablesFieldDefaultValues, +} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; +import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; + +import { FormControl } from '@@/form-components/FormControl'; +import { PortainerSelect } from '@@/form-components/PortainerSelect'; + +export function TemplateFieldset({ + value: selectedTemplate, + onChange, + onChangeFile, +}: { + value: CustomTemplate | undefined; + onChange: (value?: CustomTemplate) => void; + onChangeFile: (value: string) => void; +}) { + const fetchFileMutation = useCustomTemplateFileMutation(); + const [templateFile, setTemplateFile] = useState(''); + const templatesQuery = useCustomTemplates({ + select: (templates) => + templates.filter((template) => template.EdgeTemplate), + }); + + const [variableValues, setVariableValues] = useState([]); + + return ( + <> + + {selectedTemplate && ( + <> + {selectedTemplate.Note && ( +
+
Information
+
+
+
+
+
+
+ )} + + { + setVariableValues(value); + onChangeFile( + renderTemplate(templateFile, value, selectedTemplate.Variables) + ); + }} + value={variableValues} + definitions={selectedTemplate.Variables} + /> + + )} + + ); + + function handleChangeTemplate(templateId: CustomTemplate['Id'] | undefined) { + const selectedTemplate = templatesQuery.data?.find( + (template) => template.Id === templateId + ); + if (!selectedTemplate) { + setVariableValues([]); + onChange(undefined); + return; + } + + fetchFileMutation.mutate( + { id: selectedTemplate.Id, git: !!selectedTemplate.GitConfig }, + { + onSuccess: (data) => { + setTemplateFile(data); + onChangeFile( + renderTemplate( + data, + getVariablesFieldDefaultValues(selectedTemplate.Variables), + selectedTemplate.Variables + ) + ); + }, + } + ); + setVariableValues( + selectedTemplate + ? getVariablesFieldDefaultValues(selectedTemplate.Variables) + : [] + ); + onChange(selectedTemplate); + } +} + +function TemplateSelector({ + value, + onChange, +}: { + value: CustomTemplate['Id'] | undefined; + onChange: (value: CustomTemplate['Id'] | undefined) => void; +}) { + const templatesQuery = useCustomTemplates({ + select: (templates) => + templates.filter((template) => template.EdgeTemplate), + }); + + if (!templatesQuery.data) { + return null; + } + + return ( + + ({ + label: `${template.Title} - ${template.Description}`, + value: template.Id, + }))} + /> + + ); + + function handleChange(value: CustomTemplate['Id']) { + onChange(value); + } +} diff --git a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx index 0f8b35f27..8f8777099 100644 --- a/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx +++ b/app/react/edge/templates/AppTemplatesView/AppTemplatesView.tsx @@ -34,7 +34,6 @@ export function AppTemplatesView() { onSelect={(template) => setSelectedTemplateId(template.Id)} disabledTypes={[TemplateType.Container]} fixedCategories={['edge']} - hideDuplicate /> ); diff --git a/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx b/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx new file mode 100644 index 000000000..47bed8113 --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx @@ -0,0 +1,107 @@ +import { Formik } from 'formik'; +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; + +import { StackType } from '@/react/common/stacks/types'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation'; +import { Platform } from '@/react/portainer/templates/types'; +import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile'; + +import { editor } from '@@/BoxSelector/common-options/build-methods'; + +import { InnerForm } from './InnerForm'; +import { FormValues } from './types'; +import { useValidation } from './useValidation'; + +export function CreateTemplateForm() { + const router = useRouter(); + const mutation = useCreateTemplateMutation(); + const validation = useValidation(); + const { appTemplateId, type } = useParams(); + + const fileContentQuery = useFetchTemplateFile(appTemplateId); + + if (fileContentQuery.isLoading) { + return null; + } + + const initialValues: FormValues = { + Title: '', + FileContent: fileContentQuery.data ?? '', + Type: type, + File: undefined, + Method: editor.value, + Description: '', + Note: '', + Logo: '', + Platform: Platform.LINUX, + Variables: [], + Git: { + RepositoryURL: '', + RepositoryReferenceName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + ComposeFilePathInRepository: 'docker-compose.yml', + AdditionalFiles: [], + RepositoryURLValid: true, + TLSSkipVerify: false, + }, + }; + + return ( + + + + ); + + function handleSubmit(values: FormValues) { + mutation.mutate( + { ...values, EdgeTemplate: true }, + { + onSuccess() { + notifySuccess('Success', 'Template created'); + router.stateService.go('^'); + }, + } + ); + } +} + +function useParams() { + const { + params: { type = StackType.DockerCompose, appTemplateId }, + } = useCurrentStateAndParams(); + + return { + type: getStackType(type), + appTemplateId: getTemplateId(appTemplateId), + }; + + function getStackType(type: string): StackType { + const typeNum = parseInt(type, 10); + + if ( + [ + StackType.DockerSwarm, + StackType.DockerCompose, + StackType.Kubernetes, + ].includes(typeNum) + ) { + return typeNum; + } + + return StackType.DockerCompose; + } + + function getTemplateId(appTemplateId: string): number | undefined { + const id = parseInt(appTemplateId, 10); + + return Number.isNaN(id) ? undefined : id; + } +} diff --git a/app/react/edge/templates/custom-templates/CreateView/CreateView.tsx b/app/react/edge/templates/custom-templates/CreateView/CreateView.tsx new file mode 100644 index 000000000..0e1f7804e --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/CreateView.tsx @@ -0,0 +1,28 @@ +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; + +import { CreateTemplateForm } from './CreateTemplateForm'; + +export function CreateView() { + return ( +
+ + +
+
+ + + + + +
+
+
+ ); +} diff --git a/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx new file mode 100644 index 000000000..26142e735 --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx @@ -0,0 +1,172 @@ +import { Form, useFormikContext } from 'formik'; + +import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields'; +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 { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector'; + +import { BoxSelector } from '@@/BoxSelector'; +import { WebEditorForm, usePreventExit } from '@@/WebEditorForm'; +import { FileUploadForm } from '@@/form-components/FileUpload'; +import { FormActions } from '@@/form-components/FormActions'; +import { FormSection } from '@@/form-components/FormSection'; +import { + editor, + upload, + git, +} from '@@/BoxSelector/common-options/build-methods'; + +import { FormValues, Method, buildMethods } from './types'; + +export function InnerForm({ isLoading }: { isLoading: boolean }) { + const { + values, + initialValues, + setFieldValue, + errors, + isValid, + setFieldError, + setValues, + isSubmitting, + } = useFormikContext(); + + usePreventExit( + initialValues.FileContent, + values.FileContent, + values.Method === editor.value && !isSubmitting + ); + return ( +
+ + setValues((values) => ({ ...values, ...newValues })) + } + errors={errors} + /> + + setFieldValue('Platform', value)} + /> + + setFieldValue('Type', value)} + /> + + + + + + {values.Method === editor.value && ( + +

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

+
+ )} + + {values.Method === upload.value && ( + setFieldValue('File', value)} + required + /> + )} + + {values.Method === git.value && ( + + setValues((values) => ({ + ...values, + Git: { ...values.Git, ...newValues }, + })) + } + errors={errors.Git} + /> + )} + + {isTemplateVariablesEnabled && ( + setFieldValue('Variables', values)} + isVariablesNamesFromParent={values.Method === editor.value} + errors={errors.Variables} + /> + )} + + + + ); + + 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', []); + setFieldValue('Method', method); + } +} diff --git a/app/react/edge/templates/custom-templates/CreateView/index.ts b/app/react/edge/templates/custom-templates/CreateView/index.ts new file mode 100644 index 000000000..74e592112 --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/index.ts @@ -0,0 +1 @@ +export { CreateView } from './CreateView'; diff --git a/app/react/edge/templates/custom-templates/CreateView/types.ts b/app/react/edge/templates/custom-templates/CreateView/types.ts new file mode 100644 index 000000000..48c34719c --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/types.ts @@ -0,0 +1,25 @@ +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 { GitFormModel } from '@/react/portainer/gitops/types'; + +import { + editor, + upload, + git, +} from '@@/BoxSelector/common-options/build-methods'; + +export const buildMethods = [editor, upload, git] as const; + +export type Method = (typeof buildMethods)[number]['value']; + +export interface FormValues extends CommonFieldsValues { + Platform: Platform; + Type: StackType; + Method: Method; + FileContent: string; + File: File | undefined; + Git: GitFormModel; + Variables: DefinitionFieldValues; +} diff --git a/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx b/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx new file mode 100644 index 000000000..b7e0fad6f --- /dev/null +++ b/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx @@ -0,0 +1,59 @@ +import { mixed, number, object, string } from 'yup'; +import { useMemo } from 'react'; + +import { StackType } from '@/react/common/stacks/types'; +import { validation as commonFieldsValidation } from '@/react/portainer/custom-templates/components/CommonFields'; +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 { file } from '@@/form-components/yup-file-validation'; +import { + editor, + git, + upload, +} from '@@/BoxSelector/common-options/build-methods'; + +import { buildMethods } from './types'; + +export function useValidation() { + const { user } = useCurrentUser(); + const gitCredentialsQuery = useGitCredentials(user.Id); + const customTemplatesQuery = useCustomTemplates(); + + return useMemo( + () => + object({ + Platform: number() + .oneOf([Platform.LINUX, Platform.WINDOWS]) + .default(Platform.LINUX), + Type: number() + .oneOf([ + StackType.DockerCompose, + StackType.DockerSwarm, + StackType.Kubernetes, + ]) + .default(StackType.DockerCompose), + Method: string().oneOf(buildMethods.map((m) => m.value)), + FileContent: string().when('Method', { + is: editor.value, + then: (schema) => schema.required('Template is required.'), + }), + File: file().when('Method', { + is: upload.value, + then: (schema) => schema.required(), + }), + Git: mixed().when('Method', { + is: git.value, + then: () => buildGitValidationSchema(gitCredentialsQuery.data || []), + }), + Variables: variablesValidation(), + }).concat( + commonFieldsValidation({ templates: customTemplatesQuery.data }) + ), + [customTemplatesQuery.data, gitCredentialsQuery.data] + ); +} diff --git a/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx b/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx new file mode 100644 index 000000000..4e81f4048 --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/EditTemplateForm.tsx @@ -0,0 +1,99 @@ +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 { 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); + + 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, + }; + + 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, + ...values.Git, + }, + { + 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 new file mode 100644 index 000000000..04302595b --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/EditView.tsx @@ -0,0 +1,49 @@ +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 }, + } = useCurrentStateAndParams(); + const customTemplateQuery = useCustomTemplate(id); + + 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/edge/templates/custom-templates/EditView/InnerForm.tsx b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx new file mode 100644 index 000000000..c7358c80b --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx @@ -0,0 +1,170 @@ +import { Form, useFormikContext } from 'formik'; +import { RefreshCw } from 'lucide-react'; + +import { CommonFields } from '@/react/portainer/custom-templates/components/CommonFields'; +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 { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector'; + +import { WebEditorForm, usePreventExit } from '@@/WebEditorForm'; +import { FormActions } from '@@/form-components/FormActions'; +import { Button } from '@@/buttons'; +import { FormError } from '@@/form-components/FormError'; + +import { FormValues } from './types'; + +export function InnerForm({ + isLoading, + isEditorReadonly, + gitFileContent, + gitFileError, + refreshGitFile, +}: { + isLoading: boolean; + isEditorReadonly: boolean; + gitFileContent?: string; + gitFileError?: string; + refreshGitFile: () => void; +}) { + const { + values, + initialValues, + setFieldValue, + errors, + isValid, + setFieldError, + isSubmitting, + dirty, + setValues, + } = useFormikContext(); + + usePreventExit( + initialValues.FileContent, + values.FileContent, + !isEditorReadonly && !isSubmitting + ); + return ( +
+ + setValues((values) => ({ ...values, ...newValues })) + } + errors={errors} + /> + + setFieldValue('Platform', value)} + /> + + setFieldValue('Type', value)} + /> + + +

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

+
+ + {values.Git && ( + <> + + setValues((values) => ({ + ...values, + // set ! for values.Git because this callback will only be called when it's defined (see L94) + Git: { ...values.Git!, ...newValues }, + })) + } + errors={typeof errors.Git === 'object' ? errors.Git : undefined} + /> +
+
+ +
+ {gitFileError && ( +
+ + Custom template could not be loaded, {gitFileError}. + +
+ )} +
+ + )} + + {isTemplateVariablesEnabled && ( + setFieldValue('Variables', values)} + isVariablesNamesFromParent={!isEditorReadonly} + errors={errors.Variables} + /> + )} + + + + ); + + 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/edge/templates/custom-templates/EditView/index.ts new file mode 100644 index 000000000..854d3dd28 --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/index.ts @@ -0,0 +1 @@ +export { EditView } from './EditView'; diff --git a/app/react/edge/templates/custom-templates/EditView/types.ts b/app/react/edge/templates/custom-templates/EditView/types.ts new file mode 100644 index 000000000..a847979c1 --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/types.ts @@ -0,0 +1,13 @@ +import { StackType } from '@/react/common/stacks/types'; +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'; + +export interface FormValues extends CommonFieldsValues { + Platform: Platform; + Type: StackType; + FileContent: string; + Git?: GitFormModel; + Variables: DefinitionFieldValues; +} diff --git a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx new file mode 100644 index 000000000..7df3ec116 --- /dev/null +++ b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx @@ -0,0 +1,56 @@ +import { mixed, number, object, string } from 'yup'; +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 { 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'; + +export function useValidation( + currentTemplateId: CustomTemplate['Id'], + isGit: boolean +) { + const { user } = useCurrentUser(); + const gitCredentialsQuery = useGitCredentials(user.Id); + const customTemplatesQuery = useCustomTemplates(); + + return useMemo( + () => + object({ + Platform: number() + .oneOf([Platform.LINUX, Platform.WINDOWS]) + .default(Platform.LINUX), + Type: number() + .oneOf([ + StackType.DockerCompose, + StackType.DockerSwarm, + StackType.Kubernetes, + ]) + .default(StackType.DockerCompose), + FileContent: isGit + ? string().default('') + : string().required('Template is required.'), + + Git: isGit + ? buildGitValidationSchema(gitCredentialsQuery.data || []) + : mixed(), + Variables: variablesValidation(), + }).concat( + commonFieldsValidation({ + templates: customTemplatesQuery.data, + currentTemplateId, + }) + ), + [ + currentTemplateId, + customTemplatesQuery.data, + gitCredentialsQuery.data, + isGit, + ] + ); +} diff --git a/app/react/edge/templates/custom-templates/ListView/ListView.tsx b/app/react/edge/templates/custom-templates/ListView/ListView.tsx new file mode 100644 index 000000000..0a65f3f75 --- /dev/null +++ b/app/react/edge/templates/custom-templates/ListView/ListView.tsx @@ -0,0 +1,46 @@ +import { notifySuccess } from '@/portainer/services/notifications'; +import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates'; +import { useDeleteTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useDeleteTemplateMutation'; +import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types'; +import { CustomTemplatesList } from '@/react/portainer/templates/custom-templates/ListView/CustomTemplatesList'; + +import { PageHeader } from '@@/PageHeader'; +import { confirmDelete } from '@@/modals/confirm'; + +export function ListView() { + const templatesQuery = useCustomTemplates({ + select(templates) { + return templates.filter((t) => t.EdgeTemplate); + }, + }); + const deleteMutation = useDeleteTemplateMutation(); + + return ( + <> + + + ({ + to: 'edge.stacks.new', + params: { templateId: template.Id }, + })} + /> + + ); + + async function handleDelete(templateId: CustomTemplate['Id']) { + if ( + !(await confirmDelete('Are you sure you want to delete this template?')) + ) { + return; + } + + deleteMutation.mutate(templateId, { + onSuccess: () => { + notifySuccess('Success', 'Template deleted'); + }, + }); + } +} diff --git a/app/react/edge/templates/custom-templates/ListView/index.ts b/app/react/edge/templates/custom-templates/ListView/index.ts new file mode 100644 index 000000000..dd06dfd19 --- /dev/null +++ b/app/react/edge/templates/custom-templates/ListView/index.ts @@ -0,0 +1 @@ +export { ListView } from './ListView'; diff --git a/app/react/portainer/account/git-credentials/git-credentials.service.ts b/app/react/portainer/account/git-credentials/git-credentials.service.ts index 89086a96b..f49adc57b 100644 --- a/app/react/portainer/account/git-credentials/git-credentials.service.ts +++ b/app/react/portainer/account/git-credentials/git-credentials.service.ts @@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { success as notifySuccess } from '@/portainer/services/notifications'; +import { UserId } from '@/portainer/users/types'; + +import { isBE } from '../../feature-flags/feature-flags.service'; import { CreateGitCredentialPayload, @@ -112,9 +115,12 @@ export function useDeleteGitCredentialMutation() { }); } -export function useGitCredentials(userId: number) { +export function useGitCredentials( + userId: UserId, + { enabled }: { enabled?: boolean } = {} +) { return useQuery('gitcredentials', () => getGitCredentials(userId), { - staleTime: 20, + enabled: isBE && enabled, meta: { error: { title: 'Failure', diff --git a/app/react/portainer/custom-templates/components/CommonFields.tsx b/app/react/portainer/custom-templates/components/CommonFields.tsx index e1d0e5d81..3a2f1f43a 100644 --- a/app/react/portainer/custom-templates/components/CommonFields.tsx +++ b/app/react/portainer/custom-templates/components/CommonFields.tsx @@ -4,7 +4,9 @@ import { FormikErrors } from 'formik'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; -interface Values { +import { CustomTemplate } from '../../templates/custom-templates/types'; + +export interface Values { Title: string; Description: string; Note: string; @@ -87,11 +89,26 @@ export function CommonFields({ } export function validation({ + currentTemplateId, + templates = [], title, }: { + currentTemplateId?: CustomTemplate['Id']; + templates?: Array; title?: { pattern: string; error: string }; } = {}): SchemaOf { - let titleSchema = string().required('Title is required.'); + 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); diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx index e24ee56de..3f226a359 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField.tsx @@ -1,9 +1,9 @@ -import { FormikErrors } from 'formik'; +import { SchemaOf, array, object, string } from 'yup'; import { FormError } from '@@/form-components/FormError'; import { Input } from '@@/form-components/Input'; import { InputList } from '@@/form-components/InputList'; -import { ItemProps } from '@@/form-components/InputList/InputList'; +import { ArrayError, ItemProps } from '@@/form-components/InputList/InputList'; export interface VariableDefinition { name: string; @@ -12,10 +12,12 @@ export interface VariableDefinition { description: string; } +export type Values = VariableDefinition[]; + interface Props { - value: VariableDefinition[]; - onChange: (value: VariableDefinition[]) => void; - errors?: FormikErrors[]; + value: Values; + onChange: (value: Values) => void; + errors?: ArrayError; isVariablesNamesFromParent?: boolean; } @@ -107,3 +109,16 @@ function Item({ item, onChange, error, isNameReadonly }: DefinitionItemProps) { onChange({ ...item, [e.target.name]: e.target.value }); } } + +function itemValidation(): SchemaOf { + return object().shape({ + name: string().required('Name is required'), + label: string().required('Label is required'), + defaultValue: string().default(''), + description: string().default(''), + }); +} + +export function validation(): SchemaOf { + return array().of(itemValidation()); +} diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/index.ts b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/index.ts index 22d6a0afc..c05c96a20 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/index.ts +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/index.ts @@ -1,2 +1,8 @@ -export { CustomTemplatesVariablesDefinitionField } from './CustomTemplatesVariablesDefinitionField'; -export type { VariableDefinition } from './CustomTemplatesVariablesDefinitionField'; +export { + CustomTemplatesVariablesDefinitionField, + validation as variablesValidation, +} from './CustomTemplatesVariablesDefinitionField'; +export type { + VariableDefinition, + Values as DefinitionFieldValues, +} from './CustomTemplatesVariablesDefinitionField'; diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.stories.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.stories.tsx index 4e8364437..df6ce8b73 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.stories.tsx +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.stories.tsx @@ -4,7 +4,7 @@ import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/C import { CustomTemplatesVariablesField, - Variables, + Values, } from './CustomTemplatesVariablesField'; export default { @@ -34,10 +34,8 @@ const definitions: VariableDefinition[] = [ ]; function Template() { - const [value, setValue] = useState( - Object.fromEntries( - definitions.map((def) => [def.name, def.defaultValue || '']) - ) + const [value, setValue] = useState( + definitions.map((def) => ({ key: def.name, value: def.defaultValue || '' })) ); return ( diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx index 70fe54477..3e7d6ecc8 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/CustomTemplatesVariablesField.tsx @@ -1,18 +1,24 @@ +import { array, object, string } from 'yup'; + import { FormControl } from '@@/form-components/FormControl'; import { FormSection } from '@@/form-components/FormSection/FormSection'; import { Input } from '@@/form-components/Input'; +import { ArrayError } from '@@/form-components/InputList/InputList'; +import { FormError } from '@@/form-components/FormError'; import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; -export type Variables = Record; +export type Values = Array<{ key: string; value?: string }>; interface Props { - value: Variables; - definitions?: VariableDefinition[]; - onChange: (value: Variables) => void; + errors?: ArrayError; + value: Values; + definitions: VariableDefinition[] | undefined; + onChange: (value: Values) => void; } export function CustomTemplatesVariablesField({ + errors, value, definitions, onChange, @@ -23,32 +29,88 @@ export function CustomTemplatesVariablesField({ return ( - {definitions.map((def) => { - const inputId = `${def.name}-input`; - const variable = value[def.name] || ''; - return ( - - - onChange({ - ...value, - [def.name]: e.target.value, - }) - } - /> - - ); - })} + {definitions.map((definition, index) => ( + v.key === definition.name)?.value || ''} + error={getError(errors, index)} + onChange={(fieldValue) => { + onChange( + value.map((v) => + v.key === definition.name ? { ...v, value: fieldValue } : v + ) + ); + }} + /> + ))} + + {typeof errors === 'string' && {errors}} ); } + +function VariableFieldItem({ + definition, + value, + error, + onChange, +}: { + definition: VariableDefinition; + value: string; + error?: string; + onChange: (value: string) => void; +}) { + const inputId = `${definition.name}-input`; + + return ( + + onChange(e.target.value)} + /> + + ); +} + +function getError(errors: ArrayError | undefined, index: number) { + if (!errors || typeof errors !== 'object') { + return undefined; + } + + const error = errors[index]; + if (!error) { + return undefined; + } + + return typeof error === 'object' ? error.value : error; +} +export function validation(definitions: VariableDefinition[]) { + return array( + object({ + key: string().default(''), + value: string().default(''), + }).test('required-if-no-default-value', 'This field is required', (obj) => { + const definition = definitions.find((d) => d.name === obj.key); + if (!definition) { + return true; + } + + if (!definition.defaultValue && !obj.value) { + return false; + } + + return true; + }) + ); +} diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/getDefaultValues.ts b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/getDefaultValues.ts new file mode 100644 index 000000000..088950b5d --- /dev/null +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/getDefaultValues.ts @@ -0,0 +1,10 @@ +import { VariableDefinition } from '../CustomTemplatesVariablesDefinitionField'; + +import { Values } from './CustomTemplatesVariablesField'; + +export function getDefaultValues(definitions: VariableDefinition[]): Values { + return definitions.map((v) => ({ + key: v.name, + value: v.defaultValue, + })); +} diff --git a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts index 433d971d0..014903d90 100644 --- a/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts +++ b/app/react/portainer/custom-templates/components/CustomTemplatesVariablesField/index.ts @@ -1 +1,7 @@ -export { CustomTemplatesVariablesField } from './CustomTemplatesVariablesField'; +export { + CustomTemplatesVariablesField, + type Values as VariablesFieldValue, + validation as variablesFieldValidation, +} from './CustomTemplatesVariablesField'; + +export { getDefaultValues as getVariablesFieldDefaultValues } from './getDefaultValues'; diff --git a/app/react/portainer/custom-templates/components/PlatformSelector.tsx b/app/react/portainer/custom-templates/components/PlatformSelector.tsx index f08addba2..005c2421b 100644 --- a/app/react/portainer/custom-templates/components/PlatformSelector.tsx +++ b/app/react/portainer/custom-templates/components/PlatformSelector.tsx @@ -1,7 +1,7 @@ import { FormControl } from '@@/form-components/FormControl'; import { Select } from '@@/form-components/Input'; -import { Platform } from '../types'; +import { Platform } from '../../templates/types'; const platformOptions = [ { label: 'Linux', value: Platform.LINUX }, diff --git a/app/react/portainer/custom-templates/components/utils.ts b/app/react/portainer/custom-templates/components/utils.ts index 2c2362264..df08adcb4 100644 --- a/app/react/portainer/custom-templates/components/utils.ts +++ b/app/react/portainer/custom-templates/components/utils.ts @@ -1,34 +1,48 @@ import _ from 'lodash'; import Mustache from 'mustache'; +import { isBE } from '../../feature-flags/feature-flags.service'; + import { VariableDefinition } from './CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; +import { VariablesFieldValue } from './CustomTemplatesVariablesField'; + +export const isTemplateVariablesEnabled = isBE; export function getTemplateVariables(templateStr: string) { - const template = validateAndParse(templateStr); + const [template, error] = validateAndParse(templateStr); if (!template) { - return null; + return [null, error] as const; } - return template - .filter(([type, value]) => type === 'name' && value) - .map(([, value]) => ({ - name: value, - label: '', - defaultValue: '', - description: '', - })); + return [ + template + .filter(([type, value]) => type === 'name' && value) + .map(([, value]) => ({ + name: value, + label: '', + defaultValue: '', + description: '', + })), + null, + ] as const; } - -function validateAndParse(templateStr: string) { +type TemplateSpans = ReturnType; +function validateAndParse( + templateStr: string +): readonly [TemplateSpans, null] | readonly [null, string] { if (!templateStr) { - return []; + return [[] as TemplateSpans, null] as const; } try { - return Mustache.parse(templateStr); + return [Mustache.parse(templateStr), null] as const; } catch (e) { - return null; + if (!(e instanceof Error)) { + return [null, 'Parse error'] as const; + } + + return [null, e.message] as const; } } @@ -51,22 +65,22 @@ export function intersectVariables( export function renderTemplate( template: string, - variables: Record, + variables: VariablesFieldValue, definitions: VariableDefinition[] ) { const state = Object.fromEntries( _.compact( - Object.entries(variables).map(([name, value]) => { + variables.map(({ key, value }) => { if (value) { - return [name, value]; + return [key, value]; } - const definition = definitions.find((def) => def.name === name); + const definition = definitions.find((def) => def.name === key); if (!definition) { return null; } - return [name, definition.defaultValue || `{{ ${definition.name} }}`]; + return [key, definition.defaultValue || `{{ ${definition.name} }}`]; }) ) ); diff --git a/app/react/portainer/environments/environment.service/create.ts b/app/react/portainer/environments/environment.service/create.ts index d9ff1baf3..a2cd00c62 100644 --- a/app/react/portainer/environments/environment.service/create.ts +++ b/app/react/portainer/environments/environment.service/create.ts @@ -1,11 +1,15 @@ -import axios, { parseAxiosError } from '@/portainer/services/axios'; +import axios, { + parseAxiosError, + json2formData, + arrayToJson, +} from '@/portainer/services/axios'; import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { type TagId } from '@/portainer/tags/types'; import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { type Environment, EnvironmentCreationTypes } from '../types'; -import { arrayToJson, buildUrl, json2formData } from './utils'; +import { buildUrl } from './utils'; export interface EnvironmentMetadata { groupId?: EnvironmentGroupId; diff --git a/app/react/portainer/environments/environment.service/utils.ts b/app/react/portainer/environments/environment.service/utils.ts index 1ec5c03a3..58466bfc4 100644 --- a/app/react/portainer/environments/environment.service/utils.ts +++ b/app/react/portainer/environments/environment.service/utils.ts @@ -12,25 +12,3 @@ export function buildUrl(id?: EnvironmentId, action?: string) { return baseUrl; } - -export function arrayToJson(arr?: Array) { - if (!arr) { - return ''; - } - - return JSON.stringify(arr); -} - -export function json2formData(json: Record) { - const formData = new FormData(); - - Object.entries(json).forEach(([key, value]) => { - if (typeof value === 'undefined' || value === null) { - return; - } - - formData.append(key, value as string); - }); - - return formData; -} diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx index 599989716..284559d68 100644 --- a/app/react/portainer/gitops/GitForm.tsx +++ b/app/react/portainer/gitops/GitForm.tsx @@ -28,9 +28,9 @@ interface Props { isAdditionalFilesFieldVisible?: boolean; isForcePullVisible?: boolean; isAuthExplanationVisible?: boolean; - errors: FormikErrors; - baseWebhookUrl: string; - webhookId: string; + errors?: FormikErrors; + baseWebhookUrl?: string; + webhookId?: string; webhooksDocs?: string; } @@ -88,7 +88,7 @@ export function GitForm({ {isAdditionalFilesFieldVisible && ( handleChange({ AdditionalFiles: value })} errors={errors.AdditionalFiles} /> @@ -97,8 +97,8 @@ export function GitForm({ {value.AutoUpdate && ( handleChange({ AutoUpdate: value })} isForcePullVisible={isForcePullVisible} @@ -165,5 +165,5 @@ export function buildGitValidationSchema( RepositoryURLValid: boolean().default(false), AutoUpdate: autoUpdateValidation().nullable(), TLSSkipVerify: boolean().default(false), - }).concat(gitAuthValidation(gitCredentials, false)); + }).concat(gitAuthValidation(gitCredentials, false)) as SchemaOf; } diff --git a/app/react/portainer/gitops/RefField/RefField.tsx b/app/react/portainer/gitops/RefField/RefField.tsx index 5e581c26e..b32c6f9b4 100644 --- a/app/react/portainer/gitops/RefField/RefField.tsx +++ b/app/react/portainer/gitops/RefField/RefField.tsx @@ -18,7 +18,7 @@ interface Props { onChange(value: string): void; model: RefFieldModel; error?: string; - isUrlValid: boolean; + isUrlValid?: boolean; stackId?: StackId; } diff --git a/app/react/portainer/gitops/RefField/RefSelector.tsx b/app/react/portainer/gitops/RefField/RefSelector.tsx index 84fed1711..fc07b8223 100644 --- a/app/react/portainer/gitops/RefField/RefSelector.tsx +++ b/app/react/portainer/gitops/RefField/RefSelector.tsx @@ -18,7 +18,7 @@ export function RefSelector({ value: string; stackId?: StackId; onChange: (value: string) => void; - isUrlValid: boolean; + isUrlValid?: boolean; }) { const creds = getAuthentication(model); const payload = { diff --git a/app/react/portainer/gitops/types.ts b/app/react/portainer/gitops/types.ts index 49f75af11..41b0f9eff 100644 --- a/app/react/portainer/gitops/types.ts +++ b/app/react/portainer/gitops/types.ts @@ -1,5 +1,4 @@ export type AutoUpdateMechanism = 'Webhook' | 'Interval'; - export interface AutoUpdateResponse { /* Auto update interval */ Interval: string; @@ -26,6 +25,7 @@ export interface RepoConfigResponse { ConfigFilePath: string; Authentication?: GitAuthenticationResponse; ConfigHash: string; + TLSSkipVerify: boolean; } export type AutoUpdateModel = { @@ -52,11 +52,11 @@ export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel; export interface GitFormModel extends GitAuthModel { RepositoryURL: string; - RepositoryURLValid: boolean; + RepositoryURLValid?: boolean; ComposeFilePathInRepository: string; RepositoryAuthentication: boolean; RepositoryReferenceName?: string; - AdditionalFiles: string[]; + AdditionalFiles?: string[]; SaveCredential?: boolean; NewCredentialName?: string; @@ -78,3 +78,31 @@ export interface RelativePathModel { PerDeviceConfigsMatchType?: string; PerDeviceConfigsGroupMatchType?: string; } + +export function toGitFormModel(response?: RepoConfigResponse): GitFormModel { + if (!response) { + return { + RepositoryURL: '', + ComposeFilePathInRepository: '', + RepositoryAuthentication: false, + TLSSkipVerify: false, + }; + } + + const { URL, ReferenceName, ConfigFilePath, Authentication, TLSSkipVerify } = + response; + + return { + RepositoryURL: URL, + ComposeFilePathInRepository: ConfigFilePath, + RepositoryReferenceName: ReferenceName, + RepositoryAuthentication: !!( + Authentication && + (Authentication?.GitCredentialID || Authentication?.Username) + ), + RepositoryUsername: Authentication?.Username, + RepositoryPassword: Authentication?.Password, + RepositoryGitCredentialID: Authentication?.GitCredentialID, + TLSSkipVerify, + }; +} diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx index f1881b29f..609b1894b 100644 --- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx +++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx @@ -28,14 +28,12 @@ export function AppTemplatesList({ selectedId, disabledTypes, fixedCategories, - hideDuplicate, }: { templates?: TemplateViewModel[]; onSelect: (template: TemplateViewModel) => void; selectedId?: TemplateViewModel['Id']; disabledTypes?: Array; fixedCategories?: Array; - hideDuplicate?: boolean; }) { const [page, setPage] = useState(0); @@ -75,7 +73,6 @@ export function AppTemplatesList({ template={template} onSelect={onSelect} isSelected={selectedId === template.Id} - hideDuplicate={hideDuplicate} /> ))} {!templates &&
Loading...
} diff --git a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx index 8e150ff9a..9e0abdc82 100644 --- a/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx +++ b/app/react/portainer/templates/app-templates/AppTemplatesListItem.tsx @@ -12,12 +12,10 @@ export function AppTemplatesListItem({ template, onSelect, isSelected, - hideDuplicate = false, }: { template: TemplateViewModel; onSelect: (template: TemplateViewModel) => void; isSelected: boolean; - hideDuplicate?: boolean; }) { const duplicateCustomTemplateType = getCustomTemplateType(template.Type); @@ -30,7 +28,6 @@ export function AppTemplatesListItem({ onSelect={() => onSelect(template)} isSelected={isSelected} renderActions={ - !hideDuplicate && duplicateCustomTemplateType && (