diff --git a/app/docker/__module.js b/app/docker/__module.js index ea2c5cb8e..062026ac9 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -126,7 +126,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ views: { 'content@': { - component: 'createCustomTemplateView', + component: 'createCustomTemplatesView', }, }, }; diff --git a/app/edge/__module.js b/app/edge/__module.js index e0b31efdf..131a4f039 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -174,7 +174,7 @@ angular views: { 'content@': { - component: 'edgeCreateCustomTemplatesView', + component: 'createCustomTemplatesView', }, }, }); diff --git a/app/edge/react/views/templates.ts b/app/edge/react/views/templates.ts index c2b1caedc..3b8fe2044 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 { CreateView } from '@/react/edge/templates/custom-templates/CreateView'; -import { EditView } from '@/react/edge/templates/custom-templates/EditView'; +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'; export const templatesModule = angular .module('portainer.app.react.components.templates', []) @@ -19,10 +19,10 @@ export const templatesModule = angular r2a(withCurrentUser(withUIRouter(ListView)), []) ) .component( - 'edgeCreateCustomTemplatesView', + 'createCustomTemplatesView', r2a(withCurrentUser(withUIRouter(CreateView)), []) ) .component( 'edgeEditCustomTemplatesView', - r2a(withCurrentUser(withUIRouter(EditView)), []) + r2a(withCurrentUser(withUIRouter(EdgeEditView)), []) ).name; diff --git a/app/kubernetes/custom-templates/index.js b/app/kubernetes/custom-templates/index.js index 522b94729..ef33784cb 100644 --- a/app/kubernetes/custom-templates/index.js +++ b/app/kubernetes/custom-templates/index.js @@ -2,14 +2,12 @@ import angular from 'angular'; import { kubeCustomTemplatesView } from './kube-custom-templates-view'; import { kubeEditCustomTemplateView } from './kube-edit-custom-template-view'; -import { kubeCreateCustomTemplateView } from './kube-create-custom-template-view'; export default angular .module('portainer.kubernetes.custom-templates', []) .config(config) .component('kubeCustomTemplatesView', kubeCustomTemplatesView) - .component('kubeEditCustomTemplateView', kubeEditCustomTemplateView) - .component('kubeCreateCustomTemplateView', kubeCreateCustomTemplateView).name; + .component('kubeEditCustomTemplateView', kubeEditCustomTemplateView).name; function config($stateRegistryProvider) { const templates = { @@ -38,7 +36,7 @@ function config($stateRegistryProvider) { views: { 'content@': { - component: 'kubeCreateCustomTemplateView', + component: 'createCustomTemplatesView', }, }, params: { diff --git a/app/kubernetes/custom-templates/kube-create-custom-template-view/index.js b/app/kubernetes/custom-templates/kube-create-custom-template-view/index.js deleted file mode 100644 index 203af9545..000000000 --- a/app/kubernetes/custom-templates/kube-create-custom-template-view/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import controller from './kube-create-custom-template-view.controller.js'; - -export const kubeCreateCustomTemplateView = { - templateUrl: './kube-create-custom-template-view.html', - controller, -}; 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 deleted file mode 100644 index 92d58f218..000000000 --- a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js +++ /dev/null @@ -1,242 +0,0 @@ -import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel'; -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'; - -class KubeCreateCustomTemplateViewController { - /* @ngInject */ - constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { - Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); - - this.methodOptions = [editor, upload, git]; - - this.templates = null; - this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; - - this.state = { - method: 'editor', - actionInProgress: false, - formValidationError: '', - isEditorDirty: false, - isTemplateValid: true, - }; - - this.formValues = { - FileContent: '', - File: null, - Title: '', - Description: '', - Note: '', - Logo: '', - AccessControlData: new AccessControlFormData(), - Variables: [], - RepositoryURL: '', - RepositoryURLValid: false, - RepositoryReferenceName: 'refs/heads/main', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - ComposeFilePathInRepository: 'manifest.yml', - }; - - 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.onChangeFile = this.onChangeFile.bind(this); - this.onChangeFileContent = this.onChangeFileContent.bind(this); - this.onChangeMethod = this.onChangeMethod.bind(this); - this.onBeforeOnload = this.onBeforeOnload.bind(this); - this.handleChange = this.handleChange.bind(this); - this.onVariablesChange = this.onVariablesChange.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 }); - } - - onChangeMethod(method) { - this.state.method = method; - this.formValues.Variables = []; - } - - onChangeFileContent(content) { - this.handleChange({ FileContent: content }); - this.parseTemplate(content); - 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)); - } - } - - onVariablesChange(value) { - this.handleChange({ Variables: value }); - } - - onChangeFile(file) { - this.handleChange({ File: file }); - } - - handleChange(values) { - return this.$async(async () => { - this.formValues = { - ...this.formValues, - ...values, - }; - }); - } - - async createCustomTemplate() { - return this.$async(async () => { - const { method } = this.state; - - if (!this.validateForm(method)) { - return; - } - - this.state.actionInProgress = true; - try { - const customTemplate = await this.createCustomTemplateByMethod(method, this.formValues); - - const accessControlData = this.formValues.AccessControlData; - const userDetails = this.Authentication.getUserDetails(); - const userId = userDetails.ID; - await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl); - - this.Notifications.success('Success', 'Custom template successfully created'); - this.state.isEditorDirty = false; - this.$state.go('kubernetes.templates.custom'); - } catch (err) { - this.Notifications.error('Failure', err, 'Failed creating custom template'); - } finally { - this.state.actionInProgress = false; - } - }); - } - - createCustomTemplateByMethod(method, template) { - template.Type = 3; - - switch (method) { - case 'editor': - return this.createCustomTemplateFromFileContent(template); - case 'upload': - return this.createCustomTemplateFromFileUpload(template); - case 'repository': - return this.createCustomTemplateFromGitRepository(template); - } - } - - createCustomTemplateFromFileContent(template) { - return this.CustomTemplateService.createCustomTemplateFromFileContent(template); - } - - createCustomTemplateFromFileUpload(template) { - return this.CustomTemplateService.createCustomTemplateFromFileUpload(template); - } - - createCustomTemplateFromGitRepository(template) { - return this.CustomTemplateService.createCustomTemplateFromGitRepository(template); - } - - validateForm(method) { - this.state.formValidationError = ''; - - if (method === 'editor' && this.formValues.FileContent === '') { - this.state.formValidationError = 'Template file content must not be empty'; - return false; - } - - const title = this.formValues.Title; - const isNotUnique = this.templates.some((template) => template.Title === title); - if (isNotUnique) { - this.state.formValidationError = 'A template with the same name already exists'; - return false; - } - - if (!this.state.isTemplateValid) { - this.state.formValidationError = 'Template is not valid'; - 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; - } - - async $onInit() { - return this.$async(async () => { - const { fileContent, type } = this.$state.params; - - this.formValues.FileContent = fileContent; - this.parseTemplate(fileContent); - if (type) { - this.formValues.Type = +type; - } - - try { - this.templates = await this.CustomTemplateService.customTemplates(3); - } catch (err) { - this.Notifications.error('Failure loading', err, 'Failed loading custom templates'); - } - - this.state.loading = false; - - window.addEventListener('beforeunload', this.onBeforeOnload); - }); - } - - $onDestroy() { - window.removeEventListener('beforeunload', this.onBeforeOnload); - } - - isEditorDirty() { - return this.state.method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty; - } - - onBeforeOnload(event) { - if (this.isEditorDirty()) { - event.preventDefault(); - event.returnValue = ''; - } - } - - uiCanExit() { - if (this.isEditorDirty()) { - return confirmWebEditorDiscard(); - } - } -} - -export default KubeCreateCustomTemplateViewController; diff --git a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html deleted file mode 100644 index f00ce4d06..000000000 --- a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html +++ /dev/null @@ -1,73 +0,0 @@ - - -
-
- - -
- - - -
Build method
- - -
- - -

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

-

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

-
-
-
- - - You can upload a Manifest file from your computer. - - - - - - - - - -
Actions
-
-
- - - {{ $ctrl.state.formValidationError }} - -
-
- -
-
-
-
-
diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html deleted file mode 100644 index ac36391b5..000000000 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html +++ /dev/null @@ -1,119 +0,0 @@ - - -
-
- - -
- - - - - - - -
-
Build method
- - -
- - - - -

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

-
-
- - -
-
Upload
-
- You can upload a Compose file from your computer. -
-
-
- - - {{ $ctrl.formValues.File.name }} - -
-
-
- - - - -
-
-
- - Template is invalid. -
-
-
- - - - - - - -
Actions
-
-
- - - {{ $ctrl.state.formValidationError }} - -
-
- -
-
-
-
-
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 deleted file mode 100644 index c1b170f5d..000000000 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js +++ /dev/null @@ -1,271 +0,0 @@ -import _ from 'lodash'; -import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel'; -import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants'; -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/useFetchTemplateFile'; - -class CreateCustomTemplateViewController { - /* @ngInject */ - constructor($async, $state, $scope, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) { - Object.assign(this, { - $async, - $state, - $window, - $scope, - Authentication, - CustomTemplateService, - FormValidator, - Notifications, - ResourceControlService, - StackService, - StateManager, - }); - - this.buildMethods = [editor, upload, git]; - - this.isTemplateVariablesEnabled = isTemplateVariablesEnabled; - - this.formValues = { - Title: '', - FileContent: '', - File: null, - RepositoryURL: '', - RepositoryReferenceName: '', - RepositoryAuthentication: false, - RepositoryUsername: '', - RepositoryPassword: '', - ComposeFilePathInRepository: 'docker-compose.yml', - Description: '', - Note: '', - Logo: '', - Platform: 1, - Type: 1, - AccessControlData: new AccessControlFormData(), - Variables: [], - TLSSkipVerify: false, - }; - - this.state = { - Method: 'editor', - formValidationError: '', - actionInProgress: false, - fromStack: false, - loading: true, - isEditorDirty: false, - isTemplateValid: true, - }; - - 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.createCustomTemplate = this.createCustomTemplate.bind(this); - this.createCustomTemplateAsync = this.createCustomTemplateAsync.bind(this); - this.validateForm = this.validateForm.bind(this); - this.createCustomTemplateByMethod = this.createCustomTemplateByMethod.bind(this); - this.createCustomTemplateFromFileContent = this.createCustomTemplateFromFileContent.bind(this); - this.createCustomTemplateFromFileUpload = this.createCustomTemplateFromFileUpload.bind(this); - this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this); - this.editorUpdate = this.editorUpdate.bind(this); - this.onChangeMethod = this.onChangeMethod.bind(this); - this.onVariablesChange = this.onVariablesChange.bind(this); - this.handleChange = this.handleChange.bind(this); - this.onChangePlatform = this.onChangePlatform.bind(this); - this.onChangeType = this.onChangeType.bind(this); - } - - onVariablesChange(value) { - this.handleChange({ Variables: value }); - } - - onChangePlatform(value) { - this.handleChange({ Platform: value }); - } - - onChangeType(value) { - this.handleChange({ Type: value }); - } - - handleChange(values) { - return this.$async(async () => { - this.formValues = { - ...this.formValues, - ...values, - }; - }); - } - - createCustomTemplate() { - return this.$async(this.createCustomTemplateAsync); - } - - onChangeMethod(method) { - return this.$scope.$evalAsync(() => { - this.formValues.FileContent = ''; - this.formValues.Variables = []; - this.selectedTemplate = null; - this.state.Method = method; - }); - } - - async createCustomTemplateAsync() { - let method = this.state.Method; - - if (method === 'template') { - method = 'editor'; - } - - if (!this.validateForm(method)) { - return; - } - - this.state.actionInProgress = true; - try { - const customTemplate = await this.createCustomTemplateByMethod(method); - - const accessControlData = this.formValues.AccessControlData; - const userDetails = this.Authentication.getUserDetails(); - const userId = userDetails.ID; - await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl); - - this.Notifications.success('Success', 'Custom template successfully created'); - this.state.isEditorDirty = false; - this.$state.go('docker.templates.custom'); - } catch (err) { - this.Notifications.error('Failure', err, 'A template with the same name already exists'); - } finally { - this.state.actionInProgress = false; - } - } - - validateForm(method) { - this.state.formValidationError = ''; - - if (method === 'editor' && this.formValues.FileContent === '') { - this.state.formValidationError = 'Template file content must not be empty'; - return false; - } - - const title = this.formValues.Title; - const isNotUnique = _.some(this.templates, (template) => template.Title === title); - if (isNotUnique) { - this.state.formValidationError = 'A template with the same name 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; - } - - createCustomTemplateByMethod(method) { - switch (method) { - case 'editor': - return this.createCustomTemplateFromFileContent(); - case 'upload': - return this.createCustomTemplateFromFileUpload(); - case 'repository': - return this.createCustomTemplateFromGitRepository(); - } - } - - createCustomTemplateFromFileContent() { - return this.CustomTemplateService.createCustomTemplateFromFileContent(this.formValues); - } - - createCustomTemplateFromFileUpload() { - return this.CustomTemplateService.createCustomTemplateFromFileUpload(this.formValues); - } - - createCustomTemplateFromGitRepository() { - return this.CustomTemplateService.createCustomTemplateFromGitRepository(this.formValues); - } - - editorUpdate(value) { - this.formValues.FileContent = value; - this.state.isEditorDirty = true; - this.parseTemplate(value); - } - - 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)); - } - } - - async $onInit() { - return this.$async(async () => { - const applicationState = this.StateManager.getState(); - - this.state.endpointMode = applicationState.endpoint.mode; - let stackType = 0; - if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') { - this.isDockerStandalone = true; - stackType = 2; - } else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') { - stackType = 1; - } - this.formValues.Type = stackType; - - const { appTemplateId, type } = this.$state.params; - - if (type) { - this.formValues.Type = +type; - } - - if (appTemplateId) { - this.formValues.FileContent = await fetchFilePreview(appTemplateId); - } - - try { - this.templates = await this.CustomTemplateService.customTemplates([1, 2]); - } catch (err) { - this.Notifications.error('Failure loading', err, 'Failed loading custom templates'); - } - - this.state.loading = false; - - this.$window.onbeforeunload = () => { - if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) { - return ''; - } - }; - }); - } - - $onDestroy() { - this.state.isEditorDirty = false; - } - - async uiCanExit() { - if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) { - return confirmWebEditorDiscard(); - } - } -} - -export default CreateCustomTemplateViewController; diff --git a/app/portainer/views/custom-templates/create-custom-template-view/index.js b/app/portainer/views/custom-templates/create-custom-template-view/index.js deleted file mode 100644 index 794d87f27..000000000 --- a/app/portainer/views/custom-templates/create-custom-template-view/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import CreateCustomTemplateViewController from './createCustomTemplateViewController.js'; - -angular.module('portainer.app').component('createCustomTemplateView', { - templateUrl: './createCustomTemplateView.html', - controller: CreateCustomTemplateViewController, -}); diff --git a/app/react/common/stacks/common/form-texts.tsx b/app/react/common/stacks/common/form-texts.tsx new file mode 100644 index 000000000..6423092c7 --- /dev/null +++ b/app/react/common/stacks/common/form-texts.tsx @@ -0,0 +1,51 @@ +import { StackType } from '../types'; + +const dockerTexts = { + editor: { + placeholder: 'Define or paste the content of your docker compose file here', + description: ( +

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

+ ), + }, + upload: 'You can upload a Compose file from your computer.', +} as const; + +export const textByType = { + [StackType.DockerCompose]: dockerTexts, + [StackType.DockerSwarm]: dockerTexts, + [StackType.Kubernetes]: { + editor: { + placeholder: 'Define or paste the content of your manifest file here', + description: ( + <> +

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

+

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

+ + ), + }, + upload: 'You can upload a Manifest file from your computer.', + }, +} as const; diff --git a/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx b/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx deleted file mode 100644 index c7f4436b1..000000000 --- a/app/react/edge/templates/custom-templates/CreateView/CreateTemplateForm.tsx +++ /dev/null @@ -1,120 +0,0 @@ -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 { getDefaultEdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; -import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation'; - -import { editor } from '@@/BoxSelector/common-options/build-methods'; - -import { toGitRequest } from '../common/git'; - -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 { saveCredentials, isLoading: isSaveCredentialsLoading } = - useSaveCredentialsIfRequired(); - - 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, - }, - EdgeSettings: getDefaultEdgeTemplateSettings(), - }; - - return ( - - - - ); - - async function handleSubmit(values: FormValues) { - const credentialId = await saveCredentials(values.Git); - - mutation.mutate( - { - ...values, - EdgeTemplate: true, - Git: toGitRequest(values.Git, credentialId), - }, - { - 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 deleted file mode 100644 index 0e1f7804e..000000000 --- a/app/react/edge/templates/custom-templates/CreateView/CreateView.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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/EditView/InnerForm.tsx b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx index ede5f4839..6748c8a4e 100644 --- a/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx +++ b/app/react/edge/templates/custom-templates/EditView/InnerForm.tsx @@ -13,14 +13,13 @@ import { 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 { WebEditorForm, usePreventExit } from '@@/WebEditorForm'; import { FormActions } from '@@/form-components/FormActions'; import { Button } from '@@/buttons'; import { FormError } from '@@/form-components/FormError'; -import { EdgeSettingsFieldset } from '../CreateView/EdgeSettingsFieldset'; - import { FormValues } from './types'; export function InnerForm({ diff --git a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx index 60dcc2b66..b7e871244 100644 --- a/app/react/edge/templates/custom-templates/EditView/useValidation.tsx +++ b/app/react/edge/templates/custom-templates/EditView/useValidation.tsx @@ -10,8 +10,7 @@ import { useGitCredentials } from '@/react/portainer/account/git-credentials/git 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 '../CreateView/EdgeSettingsFieldset.validation'; +import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation'; export function useValidation( currentTemplateId: CustomTemplate['Id'], diff --git a/app/react/portainer/access-control/EditDetails/TeamsField.tsx b/app/react/portainer/access-control/EditDetails/TeamsField.tsx index 02f9f3aa3..73a6ec0a1 100644 --- a/app/react/portainer/access-control/EditDetails/TeamsField.tsx +++ b/app/react/portainer/access-control/EditDetails/TeamsField.tsx @@ -43,7 +43,7 @@ export function TeamsField({ /> ) : ( - You have not yet created any teams. Head over to the + You have not yet created any teams. Head over to the{' '} Teams view to manage teams. )} diff --git a/app/react/portainer/access-control/EditDetails/UsersField.tsx b/app/react/portainer/access-control/EditDetails/UsersField.tsx index 301f44eab..9d9191e9b 100644 --- a/app/react/portainer/access-control/EditDetails/UsersField.tsx +++ b/app/react/portainer/access-control/EditDetails/UsersField.tsx @@ -34,7 +34,7 @@ export function UsersField({ name, users, value, onChange, errors }: Props) { /> ) : ( - You have not yet created any users. Head over to the + You have not yet created any users. Head over to the{' '} Users view to manage users. )} diff --git a/app/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation.ts b/app/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation.ts index 5be9abdff..27b40c6ec 100644 --- a/app/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation.ts +++ b/app/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation.ts @@ -2,8 +2,9 @@ import { useQueryClient, useMutation } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { notifyError, notifySuccess } from '@/portainer/services/notifications'; -import { GitAuthModel } from '@/react/portainer/gitops/types'; +import { GitAuthModel, GitFormModel } from '@/react/portainer/gitops/types'; import { useCurrentUser } from '@/react/hooks/useUser'; +import { UserId } from '@/portainer/users/types'; import { GitCredential } from '../types'; import { buildGitUrl } from '../git-credentials.service'; @@ -80,3 +81,40 @@ export function useSaveCredentialsIfRequired() { } } } + +export async function saveGitCredentialsIfNeeded( + userId: UserId, + gitModel: GitFormModel +) { + let credentialsId = gitModel.RepositoryGitCredentialID; + let username = gitModel.RepositoryUsername; + let password = gitModel.RepositoryPassword; + if ( + gitModel.SaveCredential && + gitModel.RepositoryAuthentication && + password && + username && + gitModel.NewCredentialName + ) { + const cred = await createGitCredential({ + name: gitModel.NewCredentialName, + password, + username, + userId, + }); + credentialsId = cred.id; + } + + // clear username and password if credentials are provided + if (credentialsId && username) { + username = ''; + password = ''; + } + + return { + ...gitModel, + RepositoryGitCredentialID: credentialsId, + RepositoryUsername: username, + RepositoryPassword: password, + }; +} diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts index 21ac5a570..008be1c52 100644 --- a/app/react/portainer/environments/queries/useEnvironment.ts +++ b/app/react/portainer/environments/queries/useEnvironment.ts @@ -1,26 +1,39 @@ import { useQuery } from 'react-query'; -import { getEndpoint } from '@/react/portainer/environments/environment.service'; -import { - Environment, - EnvironmentId, -} from '@/react/portainer/environments/types'; import { withError } from '@/react-tools/react-query'; +import { getDeploymentOptions, getEndpoint } from '../environment.service'; +import { Environment, EnvironmentId } from '../types'; + import { environmentQueryKeys } from './query-keys'; export function useEnvironment( - id?: EnvironmentId, - select?: (environment: Environment | null) => T + environmentId?: EnvironmentId, + select?: (environment: Environment | null) => T, + options?: { autoRefreshRate?: number } ) { return useQuery( - id ? environmentQueryKeys.item(id) : [], - () => (id ? getEndpoint(id) : null), + environmentId ? environmentQueryKeys.item(environmentId) : [], + () => (environmentId ? getEndpoint(environmentId) : null), { select, ...withError('Failed loading environment'), staleTime: 50, - enabled: !!id, + enabled: !!environmentId, + refetchInterval() { + return options?.autoRefreshRate ?? false; + }, + } + ); +} + +export function useEnvironmentDeploymentOptions(id: EnvironmentId | undefined) { + return useQuery( + [...environmentQueryKeys.item(id!), 'deploymentOptions'], + () => getDeploymentOptions(id!), + { + enabled: !!id, + ...withError('Failed loading deployment options'), } ); } diff --git a/app/react/portainer/gitops/AuthFieldset/NewCredentialForm.tsx b/app/react/portainer/gitops/AuthFieldset/NewCredentialForm.tsx index b0c653bd7..983a764b1 100644 --- a/app/react/portainer/gitops/AuthFieldset/NewCredentialForm.tsx +++ b/app/react/portainer/gitops/AuthFieldset/NewCredentialForm.tsx @@ -24,12 +24,12 @@ export function NewCredentialForm({ onChange({ SaveCredential: e.target.checked })} /> method.value), + }); + + if (!initialValues) { + return null; + } + + return ( + + + + ); + + function handleSubmit(values: FormValues) { + mutation.mutate( + { + ...values, + EdgeTemplate: isEdge, + }, + { + onSuccess() { + notifySuccess('Success', 'Template created'); + router.stateService.go('^'); + }, + } + ); + } +} + +function useBuildMethods() { + const environment = useCurrentEnvironment(false); + + const deploymentOptionsQuery = useEnvironmentDeploymentOptions( + environment.data && isKubernetesEnvironment(environment.data.Type) + ? environment.data.Id + : undefined + ); + return initialBuildMethods.filter((method) => { + switch (method.value) { + case 'editor': + return !deploymentOptionsQuery.data?.hideWebEditor; + case 'upload': + return !deploymentOptionsQuery.data?.hideFileUpload; + case 'repository': + return true; + default: + return true; + } + }); +} diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx new file mode 100644 index 000000000..3b0aeb3cf --- /dev/null +++ b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx @@ -0,0 +1,51 @@ +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 { CreateForm } from './CreateForm'; + +export function CreateView() { + const defaultType = useDefaultType(); + const environmentId = useEnvironmentId(false); + + return ( +
+ + +
+
+ + + + + +
+
+
+ ); +} + +function useDefaultType() { + const { + state: { name }, + } = useCurrentStateAndParams(); + if (name?.includes('kubernetes')) { + return StackType.Kubernetes; + } + + // edge or docker + return StackType.DockerCompose; +} diff --git a/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx b/app/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx similarity index 100% rename from app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx rename to app/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.tsx diff --git a/app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts b/app/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts similarity index 100% rename from app/react/edge/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts rename to app/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation.ts diff --git a/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx similarity index 70% rename from app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx rename to app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx index 497bf6781..3c84f306f 100644 --- a/app/react/edge/templates/custom-templates/CreateView/InnerForm.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/InnerForm.tsx @@ -10,24 +10,33 @@ import { isTemplateVariablesEnabled, } from '@/react/portainer/custom-templates/components/utils'; import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector'; -import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types'; +import { AccessControlForm } from '@/react/portainer/access-control'; +import { EnvironmentId } from '@/react/portainer/environments/types'; import { applySetStateAction } from '@/react-tools/apply-set-state-action'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import { textByType } from '@/react/common/stacks/common/form-texts'; +import { StackType } from '@/react/common/stacks/types'; 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'; +import { EdgeTemplateSettings } from '../types'; + import { EdgeSettingsFieldset } from './EdgeSettingsFieldset'; +import { FormValues, Method, initialBuildMethods } from './types'; -export function InnerForm({ isLoading }: { isLoading: boolean }) { +export function InnerForm({ + isLoading, + environmentId, + buildMethods, +}: { + isLoading: boolean; + environmentId?: EnvironmentId; + buildMethods: Array<(typeof initialBuildMethods)[number]>; +}) { const { values, initialValues, @@ -39,13 +48,17 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) { isSubmitting, } = useFormikContext(); + const isGit = values.Method === 'repository'; + const isEditor = values.Method === 'editor'; + usePreventExit( initialValues.FileContent, values.FileContent, - values.Method === editor.value && !isSubmitting && !isLoading + isEditor && !isSubmitting && !isLoading ); - const isGit = values.Method === git.value; + const texts = textByType[values.Type]; + return (
- setFieldValue('Platform', value)} - /> + {values.Type !== StackType.Kubernetes && ( + <> + setFieldValue('Platform', value)} + /> - setFieldValue('Type', value)} - /> + setFieldValue('Type', value)} + /> + + )} - {values.Method === editor.value && ( + {isEditor && ( -

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

+ {texts.editor.description}
)} - {values.Method === upload.value && ( + {values.Method === 'upload' && ( setFieldValue('File', value)} required @@ -110,6 +117,9 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) { {isGit && ( setValues((values) => ({ @@ -125,11 +135,21 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) { setFieldValue('Variables', values)} - isVariablesNamesFromParent={values.Method === editor.value} + isVariablesNamesFromParent={isEditor} errors={errors.Variables} /> )} + {!!values.AccessControl && ( + setFieldValue('AccessControl', values)} + values={values.AccessControl} + errors={errors.AccessControl as FormikErrors} + formNamespace="accessControl" + /> + )} + {values.EdgeSettings && ( diff --git a/app/react/edge/templates/custom-templates/CreateView/index.ts b/app/react/portainer/templates/custom-templates/CreateView/index.ts similarity index 100% rename from app/react/edge/templates/custom-templates/CreateView/index.ts rename to app/react/portainer/templates/custom-templates/CreateView/index.ts diff --git a/app/react/edge/templates/custom-templates/CreateView/types.ts b/app/react/portainer/templates/custom-templates/CreateView/types.ts similarity index 71% rename from app/react/edge/templates/custom-templates/CreateView/types.ts rename to app/react/portainer/templates/custom-templates/CreateView/types.ts index 800045e58..4c37b473c 100644 --- a/app/react/edge/templates/custom-templates/CreateView/types.ts +++ b/app/react/portainer/templates/custom-templates/CreateView/types.ts @@ -3,7 +3,7 @@ import { type Values as CommonFieldsValues } from '@/react/portainer/custom-temp import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; import { Platform } from '@/react/portainer/templates/types'; 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 { editor, @@ -11,9 +11,11 @@ import { git, } from '@@/BoxSelector/common-options/build-methods'; -export const buildMethods = [editor, upload, git] as const; +import { EdgeTemplateSettings } from '../types'; -export type Method = (typeof buildMethods)[number]['value']; +export const initialBuildMethods = [editor, upload, git] as const; + +export type Method = (typeof initialBuildMethods)[number]['value']; export interface FormValues extends CommonFieldsValues { Platform: Platform; @@ -23,5 +25,6 @@ export interface FormValues extends CommonFieldsValues { File: File | undefined; Git: GitFormModel; Variables: DefinitionFieldValues; + AccessControl?: AccessControlFormData; EdgeSettings?: EdgeTemplateSettings; } diff --git a/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts new file mode 100644 index 000000000..86201829a --- /dev/null +++ b/app/react/portainer/templates/custom-templates/CreateView/useInitialValues.ts @@ -0,0 +1,94 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; + +import { parseAccessControlFormData } from '@/react/portainer/access-control/utils'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { StackType } from '@/react/common/stacks/types'; + +import { Platform } from '../../types'; +import { useFetchTemplateFile } from '../../app-templates/queries/useFetchTemplateFile'; +import { getDefaultEdgeTemplateSettings } from '../types'; + +import { FormValues, Method } from './types'; + +export function useInitialValues({ + defaultType, + isEdge = false, + buildMethods, +}: { + defaultType: StackType; + isEdge?: boolean; + buildMethods: Array; +}): FormValues | undefined { + const { user, isAdmin } = useCurrentUser(); + const { appTemplateId, type = defaultType } = useAppTemplateParams(); + + const fileContentQuery = useFetchTemplateFile(appTemplateId); + if (fileContentQuery.isLoading) { + return undefined; + } + + return { + Title: '', + FileContent: fileContentQuery.data ?? '', + Type: type, + Platform: Platform.LINUX, + File: undefined, + Method: buildMethods[0], + Description: '', + Note: '', + Logo: '', + Variables: [], + Git: { + RepositoryURL: '', + RepositoryReferenceName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + ComposeFilePathInRepository: 'docker-compose.yml', + AdditionalFiles: [], + RepositoryURLValid: true, + TLSSkipVerify: false, + }, + AccessControl: isEdge + ? undefined + : parseAccessControlFormData(isAdmin, user.Id), + EdgeSettings: isEdge ? getDefaultEdgeTemplateSettings() : undefined, + }; +} + +function useAppTemplateParams() { + const { + params: { type, appTemplateId }, + } = useCurrentStateAndParams(); + + return { + type: getStackType(type), + appTemplateId: getTemplateId(appTemplateId), + }; + + function getStackType(type: string): StackType | undefined { + if (!type) { + return undefined; + } + + const typeNum = parseInt(type, 10); + + if ( + [ + StackType.DockerSwarm, + StackType.DockerCompose, + StackType.Kubernetes, + ].includes(typeNum) + ) { + return typeNum; + } + + return undefined; + } + + 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/useValidation.tsx b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx similarity index 82% rename from app/react/edge/templates/custom-templates/CreateView/useValidation.tsx rename to app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx index 49c33c087..d169bf87e 100644 --- a/app/react/edge/templates/custom-templates/CreateView/useValidation.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/useValidation.tsx @@ -9,6 +9,7 @@ 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 { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation'; import { file } from '@@/form-components/yup-file-validation'; import { @@ -17,10 +18,9 @@ import { upload, } from '@@/BoxSelector/common-options/build-methods'; -import { buildMethods } from './types'; -import { edgeFieldsetValidation } from './EdgeSettingsFieldset.validation'; +import { initialBuildMethods } from './types'; -export function useValidation() { +export function useValidation(isEdge: boolean) { const { user } = useCurrentUser(); const gitCredentialsQuery = useGitCredentials(user.Id); const customTemplatesQuery = useCustomTemplates(); @@ -38,7 +38,7 @@ export function useValidation() { StackType.Kubernetes, ]) .default(StackType.DockerCompose), - Method: string().oneOf(buildMethods.map((m) => m.value)), + Method: string().oneOf(initialBuildMethods.map((m) => m.value)), FileContent: string().when('Method', { is: editor.value, then: (schema) => schema.required('Template is required.'), @@ -52,10 +52,10 @@ export function useValidation() { then: () => buildGitValidationSchema(gitCredentialsQuery.data || []), }), Variables: variablesValidation(), - EdgeSettings: edgeFieldsetValidation(), + EdgeSettings: isEdge ? edgeFieldsetValidation() : mixed(), }).concat( commonFieldsValidation({ templates: customTemplatesQuery.data }) ), - [customTemplatesQuery.data, gitCredentialsQuery.data] + [customTemplatesQuery.data, gitCredentialsQuery.data, isEdge] ); } diff --git a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts index 0ad51be1d..3295ff803 100644 --- a/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts +++ b/app/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation.ts @@ -10,57 +10,96 @@ import { withInvalidate, } from '@/react-tools/react-query'; import { StackType } from '@/react/common/stacks/types'; -import { FormValues } from '@/react/edge/templates/custom-templates/CreateView/types'; import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField'; import { CustomTemplate, EdgeTemplateSettings, } from '@/react/portainer/templates/custom-templates/types'; +import { GitFormModel } from '@/react/portainer/gitops/types'; +import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField'; +import { AccessControlFormData } from '@/react/portainer/access-control/types'; +import { applyResourceControl } from '@/react/portainer/access-control/access-control.service'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { UserId } from '@/portainer/users/types'; +import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation'; import { Platform } from '../../types'; import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +interface CreateTemplatePayload { + EdgeTemplate?: boolean; + Platform: Platform; + Type: StackType; + Method: 'editor' | 'upload' | 'repository'; + FileContent: string; + File: File | undefined; + Git: GitFormModel; + Variables: DefinitionFieldValues; + EdgeSettings?: EdgeTemplateSettings; + Title: string; + Description: string; + Note: string; + Logo: string; + AccessControl?: AccessControlFormData; +} export function useCreateTemplateMutation() { + const { user } = useCurrentUser(); const queryClient = useQueryClient(); return useMutation( - createTemplate, + async (payload: CreateTemplatePayload) => { + const template = await createTemplate(user.Id, payload); + const resourceControl = template.ResourceControl; + + if (resourceControl && payload.AccessControl) { + await applyResourceControl(payload.AccessControl, resourceControl.Id); + } + + return template; + }, mutationOptions( - withInvalidate(queryClient, [['custom-templates']]), + withInvalidate(queryClient, [queryKeys.base()]), withGlobalError('Failed to create template') ) ); } -function createTemplate({ - Method, - Git, - ...values -}: FormValues & { EdgeTemplate?: boolean }) { - switch (Method) { +function createTemplate(userId: UserId, payload: CreateTemplatePayload) { + switch (payload.Method) { case 'editor': - return createTemplateFromText(values); + return createTemplateFromText(payload); case 'upload': - return createTemplateFromFile(values); + return createTemplateFromFile(payload); case 'repository': - return createTemplateFromGit({ - ...values, - ...Git, - ...(values.EdgeSettings - ? { - EdgeSettings: { - ...values.EdgeSettings, - ...values.EdgeSettings.RelativePathSettings, - }, - } - : {}), - }); + return createTemplateAndGitCredential(userId, payload); default: throw new Error('Unknown method'); } } +async function createTemplateAndGitCredential( + userId: UserId, + { Git: gitModel, ...values }: CreateTemplatePayload +) { + const newGitModel = await saveGitCredentialsIfNeeded(userId, gitModel); + + return createTemplateFromGit({ + ...values, + ...newGitModel, + ...(values.EdgeSettings + ? { + EdgeSettings: { + ...values.EdgeSettings, + ...values.EdgeSettings.RelativePathSettings, + }, + } + : {}), + }); +} + /** * Payload for creating a custom template from file content. */ @@ -179,6 +218,10 @@ interface CustomTemplateFromGitRepositoryPayload { RepositoryUsername?: string; /** Password used in basic authentication when RepositoryAuthentication is true. */ RepositoryPassword?: string; + /** GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication + * is true and RepositoryUsername/RepositoryPassword are not provided + */ + RepositoryGitCredentialID?: number; /** Path to the Stack file inside the Git repository. */ ComposeFilePathInRepository: string; /** Definitions of variables in the stack file. */