From a7ed6222b005cca0f0ce12be4e9c270b1d8cab31 Mon Sep 17 00:00:00 2001 From: Alice Groux Date: Sat, 20 Mar 2021 22:13:27 +0100 Subject: [PATCH] feat(app): Prevent web editor related views from being accidentally closed (#4715) * feat(app): when leaving a view with unsaved changed, a modal prompt the user with a confirmation message feat(app): when leaving a view with unsaved changes, a modal prompt the user with a confirmation message * feat(app/web-editor): fix the modal behaviour when editing a stack details * feat(app/web-editor): add a reusable function confirmWebEditorDiscard in modal service * feat(docker/stack): fix missing dependency --- .../configs/create/createConfigController.js | 19 +++++++++++- .../images/build/buildImageController.js | 19 ++++++++++-- .../edge-job-form/edgeJobFormController.js | 1 + app/edge/components/edge-job-form/index.js | 1 + .../editEdgeStackFormController.js | 1 + .../components/edit-edge-stack-form/index.js | 1 + .../createEdgeJobView/createEdgeJobView.html | 1 + .../createEdgeJobViewController.js | 18 ++++++++++- app/edge/views/edge-jobs/edgeJob/edgeJob.html | 1 + .../edge-jobs/edgeJob/edgeJobController.js | 19 +++++++++++- .../createEdgeStackViewController.js | 19 ++++++++++-- .../editEdgeStackView/editEdgeStackView.html | 1 + .../editEdgeStackViewController.js | 19 +++++++++++- .../kubernetesConfigurationData.js | 1 + .../kubernetesConfigurationDataController.js | 1 + .../create/createConfiguration.html | 2 ++ .../create/createConfigurationController.js | 18 ++++++++++- .../configurations/edit/configuration.html | 1 + .../edit/configurationController.js | 16 ++++++++++ .../views/deploy/deployController.js | 19 +++++++++++- app/portainer/services/modalService.js | 17 ++++++++++ .../createCustomTemplateViewController.js | 31 +++++++++++++++++-- .../editCustomTemplateViewController.js | 22 +++++++++++-- .../stacks/create/createStackController.js | 17 ++++++++++ .../views/stacks/edit/stackController.js | 21 +++++++++++++ 25 files changed, 271 insertions(+), 15 deletions(-) diff --git a/app/docker/views/configs/create/createConfigController.js b/app/docker/views/configs/create/createConfigController.js index 5d1e686f9..0d764fe34 100644 --- a/app/docker/views/configs/create/createConfigController.js +++ b/app/docker/views/configs/create/createConfigController.js @@ -5,9 +5,11 @@ import angular from 'angular'; class CreateConfigController { /* @ngInject */ - constructor($async, $state, $transition$, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) { + constructor($async, $state, $transition$, $window, ModalService, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) { this.$state = $state; this.$transition$ = $transition$; + this.$window = $window; + this.ModalService = ModalService; this.Notifications = Notifications; this.ConfigService = ConfigService; this.Authentication = Authentication; @@ -24,6 +26,7 @@ class CreateConfigController { this.state = { formValidationError: '', + isEditorDirty: false, }; this.editorUpdate = this.editorUpdate.bind(this); @@ -31,6 +34,12 @@ class CreateConfigController { } async $onInit() { + this.$window.onbeforeunload = () => { + if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) { + return ''; + } + }; + if (!this.$transition$.params().id) { this.formValues.displayCodeEditor = true; return; @@ -53,6 +62,12 @@ class CreateConfigController { } } + async uiCanExit() { + if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + addLabel() { this.formValues.Labels.push({ name: '', value: '' }); } @@ -122,6 +137,7 @@ class CreateConfigController { const userId = userDetails.ID; await this.ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl); this.Notifications.success('Config successfully created'); + this.state.isEditorDirty = false; this.$state.go('docker.configs', {}, { reload: true }); } catch (err) { this.Notifications.error('Failure', err, 'Unable to create config'); @@ -130,6 +146,7 @@ class CreateConfigController { editorUpdate(cm) { this.formValues.ConfigContent = cm.getValue(); + this.state.isEditorDirty = true; } } diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js index 0503642ff..817807dc7 100644 --- a/app/docker/views/images/build/buildImageController.js +++ b/app/docker/views/images/build/buildImageController.js @@ -1,14 +1,16 @@ angular.module('portainer.docker').controller('BuildImageController', [ '$scope', - '$state', + '$window', + 'ModalService', 'BuildService', 'Notifications', 'HttpRequestHelper', - function ($scope, $state, BuildService, Notifications, HttpRequestHelper) { + function ($scope, $window, ModalService, BuildService, Notifications, HttpRequestHelper) { $scope.state = { BuildType: 'editor', actionInProgress: false, activeTab: 0, + isEditorDirty: false, }; $scope.formValues = { @@ -20,6 +22,12 @@ angular.module('portainer.docker').controller('BuildImageController', [ NodeName: null, }; + $window.onbeforeunload = () => { + if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) { + return ''; + } + }; + $scope.addImageName = function () { $scope.formValues.ImageNames.push({ Name: '' }); }; @@ -93,6 +101,13 @@ angular.module('portainer.docker').controller('BuildImageController', [ $scope.editorUpdate = function (cm) { $scope.formValues.DockerFileContent = cm.getValue(); + $scope.state.isEditorDirty = true; + }; + + this.uiCanExit = async function () { + if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) { + return ModalService.confirmWebEditorDiscard(); + } }; }, ]); diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js index 5fcf9687f..43a850ede 100644 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ b/app/edge/components/edge-job-form/edgeJobFormController.js @@ -72,6 +72,7 @@ export class EdgeJobFormController { editorUpdate(cm) { this.model.FileContent = cm.getValue(); + this.isEditorDirty = true; } associateEndpoint(endpoint) { diff --git a/app/edge/components/edge-job-form/index.js b/app/edge/components/edge-job-form/index.js index 58c34cefd..69c2437f0 100644 --- a/app/edge/components/edge-job-form/index.js +++ b/app/edge/components/edge-job-form/index.js @@ -14,5 +14,6 @@ angular.module('portainer.edge').component('edgeJobForm', { formAction: '<', formActionLabel: '@', actionInProgress: '<', + isEditorDirty: '=', }, }); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js index f9ffc1843..bcef9c9f6 100644 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js @@ -6,5 +6,6 @@ export class EditEdgeStackFormController { editorUpdate(cm) { this.model.StackFileContent = cm.getValue(); + this.isEditorDirty = true; } } diff --git a/app/edge/components/edit-edge-stack-form/index.js b/app/edge/components/edit-edge-stack-form/index.js index f0456524d..5f3d23628 100644 --- a/app/edge/components/edit-edge-stack-form/index.js +++ b/app/edge/components/edit-edge-stack-form/index.js @@ -10,5 +10,6 @@ angular.module('portainer.edge').component('editEdgeStackForm', { actionInProgress: '<', submitAction: '<', edgeGroups: '<', + isEditorDirty: '=', }, }); diff --git a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html index 6b9935fde..20df5d38b 100644 --- a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html +++ b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobView.html @@ -14,6 +14,7 @@ form-action="$ctrl.create" form-action-label="Create edge job" action-in-progress="$ctrl.state.actionInProgress" + is-editor-dirty="$ctrl.state.isEditorDirty" > diff --git a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js index 171a3e572..d510cd585 100644 --- a/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js +++ b/app/edge/views/edge-jobs/createEdgeJobView/createEdgeJobViewController.js @@ -1,8 +1,9 @@ export class CreateEdgeJobViewController { /* @ngInject */ - constructor($async, $q, $state, EdgeJobService, GroupService, Notifications, TagService) { + constructor($async, $q, $state, $window, ModalService, EdgeJobService, GroupService, Notifications, TagService) { this.state = { actionInProgress: false, + isEditorDirty: false, }; this.model = { @@ -17,6 +18,8 @@ export class CreateEdgeJobViewController { this.$async = $async; this.$q = $q; this.$state = $state; + this.$window = $window; + this.ModalService = ModalService; this.Notifications = Notifications; this.GroupService = GroupService; this.EdgeJobService = EdgeJobService; @@ -37,6 +40,7 @@ export class CreateEdgeJobViewController { try { await this.createEdgeJob(method, this.model); this.Notifications.success('Edge job successfully created'); + this.state.isEditorDirty = false; this.$state.go('edge.jobs', {}, { reload: true }); } catch (err) { this.Notifications.error('Failure', err, 'Unable to create Edge job'); @@ -52,6 +56,12 @@ export class CreateEdgeJobViewController { return this.EdgeJobService.createEdgeJobFromFileUpload(model); } + async uiCanExit() { + if (this.model.FileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async $onInit() { try { const [groups, tags] = await Promise.all([this.GroupService.groups(), this.TagService.tags()]); @@ -60,5 +70,11 @@ export class CreateEdgeJobViewController { } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve page data'); } + + this.$window.onbeforeunload = () => { + if (this.model.FileContent && this.state.isEditorDirty) { + return ''; + } + }; } } diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJob.html b/app/edge/views/edge-jobs/edgeJob/edgeJob.html index 9cdb02588..a98314bdd 100644 --- a/app/edge/views/edge-jobs/edgeJob/edgeJob.html +++ b/app/edge/views/edge-jobs/edgeJob/edgeJob.html @@ -24,6 +24,7 @@ form-action="$ctrl.update" form-action-label="Update Edge job" action-in-progress="$ctrl.state.actionInProgress" + is-editor-dirty="$ctrl.state.isEditorDirty" > diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js b/app/edge/views/edge-jobs/edgeJob/edgeJobController.js index a87b0b16e..845710f67 100644 --- a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js +++ b/app/edge/views/edge-jobs/edgeJob/edgeJobController.js @@ -2,15 +2,18 @@ import _ from 'lodash-es'; export class EdgeJobController { /* @ngInject */ - constructor($async, $q, $state, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) { + constructor($async, $q, $state, $window, ModalService, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) { this.state = { actionInProgress: false, showEditorTab: false, + isEditorDirty: false, }; this.$async = $async; this.$q = $q; this.$state = $state; + this.$window = $window; + this.ModalService = ModalService; this.EdgeJobService = EdgeJobService; this.EndpointService = EndpointService; this.FileSaver = FileSaver; @@ -43,6 +46,7 @@ export class EdgeJobController { try { await this.EdgeJobService.updateEdgeJob(model); this.Notifications.success('Edge job successfully updated'); + this.state.isEditorDirty = false; this.$state.go('edge.jobs', {}, { reload: true }); } catch (err) { this.Notifications.error('Failure', err, 'Unable to update Edge job'); @@ -121,6 +125,12 @@ export class EdgeJobController { this.state.showEditorTab = true; } + async uiCanExit() { + if (this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async $onInit() { const { id, tab } = this.$state.params; this.state.activeTab = tab; @@ -138,6 +148,7 @@ export class EdgeJobController { ]); edgeJob.FileContent = file.FileContent; + this.oldFileContent = edgeJob.FileContent; this.edgeJob = edgeJob; this.groups = groups; this.tags = tags; @@ -152,5 +163,11 @@ export class EdgeJobController { } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); } + + this.$window.onbeforeunload = () => { + if (this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) { + return ''; + } + }; } } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js index d988b20cf..7636f2bf0 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js @@ -2,8 +2,8 @@ import _ from 'lodash-es'; export class CreateEdgeStackViewController { /* @ngInject */ - constructor($state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) { - Object.assign(this, { $state, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async }); + constructor($state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) { + Object.assign(this, { $state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async }); this.formValues = { Name: '', @@ -24,6 +24,7 @@ export class CreateEdgeStackViewController { formValidationError: '', actionInProgress: false, StackType: null, + isEditorDirty: false, }; this.edgeGroups = null; @@ -41,6 +42,12 @@ export class CreateEdgeStackViewController { this.onChangeMethod = this.onChangeMethod.bind(this); } + async uiCanExit() { + if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async $onInit() { try { this.edgeGroups = await this.EdgeGroupService.groups(); @@ -55,6 +62,12 @@ export class CreateEdgeStackViewController { } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve Templates'); } + + this.$window.onbeforeunload = () => { + if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) { + return ''; + } + }; } createStack() { @@ -97,6 +110,7 @@ export class CreateEdgeStackViewController { await this.createStackByMethod(name, method); this.Notifications.success('Stack successfully deployed'); + this.state.isEditorDirty = false; this.$state.go('edge.stacks'); } catch (err) { this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); @@ -149,5 +163,6 @@ export class CreateEdgeStackViewController { editorUpdate(cm) { this.formValues.StackFileContent = cm.getValue(); + this.state.isEditorDirty = true; } } diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html index b1478d753..74bab4ec9 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html @@ -21,6 +21,7 @@ model="$ctrl.formValues" action-in-progress="$ctrl.state.actionInProgress" submit-action="$ctrl.deployStack" + is-editor-dirty="$ctrl.state.isEditorDirty" > diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js index 6f80e88db..f3b5eab75 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js @@ -2,9 +2,11 @@ import _ from 'lodash-es'; export class EditEdgeStackViewController { /* @ngInject */ - constructor($async, $state, EdgeGroupService, EdgeStackService, EndpointService, Notifications) { + constructor($async, $state, $window, ModalService, EdgeGroupService, EdgeStackService, EndpointService, Notifications) { this.$async = $async; this.$state = $state; + this.$window = $window; + this.ModalService = ModalService; this.EdgeGroupService = EdgeGroupService; this.EdgeStackService = EdgeStackService; this.EndpointService = EndpointService; @@ -16,6 +18,7 @@ export class EditEdgeStackViewController { this.state = { actionInProgress: false, activeTab: 0, + isEditorDirty: false, }; this.deployStack = this.deployStack.bind(this); @@ -38,9 +41,22 @@ export class EditEdgeStackViewController { EdgeGroups: this.stack.EdgeGroups, Prune: this.stack.Prune, }; + this.oldFileContent = this.formValues.StackFileContent; } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); } + + this.$window.onbeforeunload = () => { + if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) { + return ''; + } + }; + } + + async uiCanExit() { + if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } } filterStackEndpoints(groupIds, groups) { @@ -64,6 +80,7 @@ export class EditEdgeStackViewController { } await this.EdgeStackService.updateStack(this.stack.Id, this.formValues); this.Notifications.success('Stack successfully deployed'); + this.state.isEditorDirty = false; this.$state.go('edge.stacks'); } catch (err) { this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js index 912384ef1..b8489fab8 100644 --- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.js @@ -5,5 +5,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData', formValues: '=', isValid: '=', isCreation: '=', + isEditorDirty: '=', }, }); diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js index 30289e834..cac565252 100644 --- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js +++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationDataController.js @@ -44,6 +44,7 @@ class KubernetesConfigurationDataController { async editorUpdateAsync(cm) { this.formValues.DataYaml = cm.getValue(); + this.isEditorDirty = true; } editorUpdate(cm) { diff --git a/app/kubernetes/views/configurations/create/createConfiguration.html b/app/kubernetes/views/configurations/create/createConfiguration.html index 729713fcc..b1231d0f0 100644 --- a/app/kubernetes/views/configurations/create/createConfiguration.html +++ b/app/kubernetes/views/configurations/create/createConfiguration.html @@ -114,11 +114,13 @@ official documentation. + diff --git a/app/kubernetes/views/configurations/create/createConfigurationController.js b/app/kubernetes/views/configurations/create/createConfigurationController.js index 5c645edbc..909a462d6 100644 --- a/app/kubernetes/views/configurations/create/createConfigurationController.js +++ b/app/kubernetes/views/configurations/create/createConfigurationController.js @@ -6,9 +6,11 @@ import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelpe class KubernetesCreateConfigurationController { /* @ngInject */ - constructor($async, $state, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, KubernetesNamespaceHelper) { + constructor($async, $state, $window, ModalService, Notifications, Authentication, KubernetesConfigurationService, KubernetesResourcePoolService, KubernetesNamespaceHelper) { this.$async = $async; this.$state = $state; + this.$window = $window; + this.ModalService = ModalService; this.Notifications = Notifications; this.Authentication = Authentication; this.KubernetesConfigurationService = KubernetesConfigurationService; @@ -47,6 +49,7 @@ class KubernetesCreateConfigurationController { } await this.KubernetesConfigurationService.create(this.formValues); this.Notifications.success('Configuration succesfully created'); + this.state.isEditorDirty = false; this.$state.go('kubernetes.configurations'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to create configuration'); @@ -71,12 +74,19 @@ class KubernetesCreateConfigurationController { return this.$async(this.getConfigurationsAsync); } + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async onInit() { this.state = { actionInProgress: false, viewReady: false, alreadyExist: false, isDataValid: true, + isEditorDirty: false, }; this.formValues = new KubernetesConfigurationFormValues(); @@ -93,6 +103,12 @@ class KubernetesCreateConfigurationController { } finally { this.state.viewReady = true; } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml && this.state.isEditorDirty) { + return ''; + } + }; } $onInit() { diff --git a/app/kubernetes/views/configurations/edit/configuration.html b/app/kubernetes/views/configurations/edit/configuration.html index 450ee9f0f..f574802b8 100644 --- a/app/kubernetes/views/configurations/edit/configuration.html +++ b/app/kubernetes/views/configurations/edit/configuration.html @@ -82,6 +82,7 @@ form-values="ctrl.formValues" is-valid="ctrl.state.isDataValid" is-creation="false" + is-editor-dirty="ctrl.state.isEditorDirty" > diff --git a/app/kubernetes/views/configurations/edit/configurationController.js b/app/kubernetes/views/configurations/edit/configurationController.js index 50cd65d37..57928dbb1 100644 --- a/app/kubernetes/views/configurations/edit/configurationController.js +++ b/app/kubernetes/views/configurations/edit/configurationController.js @@ -11,6 +11,7 @@ class KubernetesConfigurationController { constructor( $async, $state, + $window, clipboard, Notifications, LocalStorage, @@ -25,6 +26,7 @@ class KubernetesConfigurationController { ) { this.$async = $async; this.$state = $state; + this.$window = $window; this.clipboard = clipboard; this.Notifications = Notifications; this.LocalStorage = LocalStorage; @@ -143,6 +145,7 @@ class KubernetesConfigurationController { this.formValues.Id = this.configuration.Id; this.formValues.Name = this.configuration.Name; this.formValues.Type = this.configuration.Type; + this.oldDataYaml = this.formValues.DataYaml; } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve configuration'); } finally { @@ -221,6 +224,12 @@ class KubernetesConfigurationController { }); } + async uiCanExit() { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async onInit() { try { this.state = { @@ -234,6 +243,7 @@ class KubernetesConfigurationController { activeTab: 0, currentName: this.$state.$current.name, isDataValid: true, + isEditorDirty: false, }; this.state.activeTab = this.LocalStorage.getActiveTab('configuration'); @@ -252,6 +262,12 @@ class KubernetesConfigurationController { } finally { this.state.viewReady = true; } + + this.$window.onbeforeunload = () => { + if (!this.formValues.IsSimple && this.formValues.DataYaml !== this.oldDataYaml && this.state.isEditorDirty) { + return ''; + } + }; } $onInit() { diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index a37200df9..105d08e32 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -5,9 +5,11 @@ import { KubernetesDeployManifestTypes } from 'Kubernetes/models/deploy'; class KubernetesDeployController { /* @ngInject */ - constructor($async, $state, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) { + constructor($async, $state, $window, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) { this.$async = $async; this.$state = $state; + this.$window = $window; + this.ModalService = ModalService; this.Notifications = Notifications; this.EndpointProvider = EndpointProvider; this.KubernetesResourcePoolService = KubernetesResourcePoolService; @@ -26,6 +28,7 @@ class KubernetesDeployController { async editorUpdateAsync(cm) { this.formValues.EditorContent = cm.getValue(); + this.state.isEditorDirty = true; } editorUpdate(cm) { @@ -46,6 +49,7 @@ class KubernetesDeployController { const compose = this.state.DeployType === this.ManifestDeployTypes.COMPOSE; await this.StackService.kubernetesDeploy(this.endpointId, this.formValues.Namespace, this.formValues.EditorContent, compose); this.Notifications.success('Manifest successfully deployed'); + this.state.isEditorDirty = false; this.$state.go('kubernetes.applications'); } catch (err) { this.Notifications.error('Unable to deploy manifest', err, 'Unable to deploy resources'); @@ -73,12 +77,19 @@ class KubernetesDeployController { return this.$async(this.getNamespacesAsync); } + async uiCanExit() { + if (this.formValues.EditorContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } + } + async onInit() { this.state = { DeployType: KubernetesDeployManifestTypes.KUBERNETES, tabLogsDisabled: true, activeTab: 0, viewReady: false, + isEditorDirty: false, }; this.formValues = {}; @@ -88,6 +99,12 @@ class KubernetesDeployController { await this.getNamespaces(); this.state.viewReady = true; + + this.$window.onbeforeunload = () => { + if (this.formValues.EditorContent && this.state.isEditorDirty) { + return ''; + } + }; } $onInit() { diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 18e6f0b14..4baee3d82 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -37,6 +37,23 @@ angular.module('portainer.app').factory('ModalService', [ }); }; + service.confirmWebEditorDiscard = confirmWebEditorDiscard; + function confirmWebEditorDiscard() { + const options = { + title: 'Are you sure ?', + message: 'You currently have unsaved changes in the editor. Are you sure you want to leave?', + buttons: { + confirm: { + label: 'Yes', + className: 'btn-danger', + }, + }, + }; + return new Promise((resolve) => { + service.confirm({ ...options, callback: (confirmed) => resolve(confirmed) }); + }); + } + service.confirmAsync = confirmAsync; function confirmAsync(options) { return new Promise((resolve) => { 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 0ee26e050..e24b54853 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 @@ -3,8 +3,20 @@ import { AccessControlFormData } from 'Portainer/components/accessControlForm/po class CreateCustomTemplateViewController { /* @ngInject */ - constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) { - Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager }); + constructor($async, $state, $window, Authentication, ModalService, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) { + Object.assign(this, { + $async, + $state, + $window, + Authentication, + ModalService, + CustomTemplateService, + FormValidator, + Notifications, + ResourceControlService, + StackService, + StateManager, + }); this.formValues = { Title: '', @@ -29,6 +41,7 @@ class CreateCustomTemplateViewController { actionInProgress: false, fromStack: false, loading: true, + isEditorDirty: false, }; this.templates = []; @@ -73,6 +86,7 @@ class CreateCustomTemplateViewController { await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl); this.Notifications.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'); @@ -133,6 +147,7 @@ class CreateCustomTemplateViewController { editorUpdate(cm) { this.formValues.FileContent = cm.getValue(); + this.state.isEditorDirty = true; } async $onInit() { @@ -161,6 +176,18 @@ class CreateCustomTemplateViewController { } this.state.loading = false; + + this.$window.onbeforeunload = () => { + if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) { + return ''; + } + }; + } + + async uiCanExit() { + if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } } } 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 aaba57872..e53e291e3 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 @@ -5,12 +5,13 @@ import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resou class EditCustomTemplateViewController { /* @ngInject */ - constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { - Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); + constructor($async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) { + Object.assign(this, { $async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService }); this.formValues = null; this.state = { formValidationError: '', + isEditorDirty: false, }; this.templates = []; @@ -32,6 +33,7 @@ class EditCustomTemplateViewController { ]); template.FileContent = file; this.formValues = template; + this.oldFileContent = this.formValues.FileContent; this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl); this.formValues.AccessControlData = new AccessControlFormData(); } catch (err) { @@ -84,6 +86,7 @@ class EditCustomTemplateViewController { await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl); this.Notifications.success('Custom template successfully updated'); + this.state.isEditorDirty = false; this.$state.go('docker.templates.custom'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to update custom template'); @@ -93,7 +96,14 @@ class EditCustomTemplateViewController { } editorUpdate(cm) { - this.formValues.fileContent = cm.getValue(); + this.formValues.FileContent = cm.getValue(); + this.state.isEditorDirty = true; + } + + async uiCanExit() { + if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) { + return this.ModalService.confirmWebEditorDiscard(); + } } async $onInit() { @@ -104,6 +114,12 @@ class EditCustomTemplateViewController { } catch (err) { this.Notifications.error('Failure loading', err, 'Failed loading custom templates'); } + + this.$window.onbeforeunload = () => { + if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) { + return ''; + } + }; } } diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 119f8d2a7..76a932e67 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -9,6 +9,8 @@ angular $scope, $state, $async, + $window, + ModalService, StackService, Authentication, Notifications, @@ -42,6 +44,13 @@ angular StackType: null, editorYamlValidationError: '', uploadYamlValidationError: '', + isEditorDirty: false, + }; + + $window.onbeforeunload = () => { + if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) { + return ''; + } }; $scope.addEnvironmentVariable = function () { @@ -148,6 +157,7 @@ angular }) .then(function success() { Notifications.success('Stack successfully deployed'); + $scope.state.isEditorDirty = false; $state.go('docker.stacks'); }) .catch(function error(err) { @@ -161,6 +171,7 @@ angular $scope.editorUpdate = function (cm) { $scope.formValues.StackFileContent = cm.getValue(); $scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames); + $scope.state.isEditorDirty = true; }; async function onFileLoadAsync(event) { @@ -221,5 +232,11 @@ angular } } + this.uiCanExit = async function () { + if ($scope.state.Method === 'editor' && $scope.formValues.StackFileContent && $scope.state.isEditorDirty) { + return ModalService.confirmWebEditorDiscard(); + } + }; + initView(); }); diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js index fdc20c67d..f7e8b05d2 100644 --- a/app/portainer/views/stacks/edit/stackController.js +++ b/app/portainer/views/stacks/edit/stackController.js @@ -3,6 +3,7 @@ angular.module('portainer.app').controller('StackController', [ '$q', '$scope', '$state', + '$window', '$transition$', 'StackService', 'NodeService', @@ -18,11 +19,13 @@ angular.module('portainer.app').controller('StackController', [ 'GroupService', 'ModalService', 'StackHelper', + 'ContainerHelper', function ( $async, $q, $scope, $state, + $window, $transition$, StackService, NodeService, @@ -46,6 +49,7 @@ angular.module('portainer.app').controller('StackController', [ externalStack: false, showEditorTab: false, yamlError: false, + isEditorDirty: false, }; $scope.formValues = { @@ -53,6 +57,12 @@ angular.module('portainer.app').controller('StackController', [ Endpoint: null, }; + $window.onbeforeunload = () => { + if ($scope.stackFileContent && $scope.state.isEditorDirty) { + return ''; + } + }; + $scope.duplicateStack = function duplicateStack(name, endpointId) { var stack = $scope.stack; var env = FormHelper.removeInvalidEnvVars(stack.Env); @@ -171,6 +181,7 @@ angular.module('portainer.app').controller('StackController', [ StackService.updateStack(stack, stackFile, env, prune) .then(function success() { Notifications.success('Stack successfully deployed'); + $scope.state.isEditorDirty = false; $state.reload(); }) .catch(function error(err) { @@ -190,8 +201,12 @@ angular.module('portainer.app').controller('StackController', [ }; $scope.editorUpdate = function (cm) { + if ($scope.stackFileContent !== cm.getValue()) { + $scope.state.isEditorDirty = true; + } $scope.stackFileContent = cm.getValue(); $scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames); + $scope.state.isEditorDirty = true; }; $scope.stopStack = stopStack; @@ -369,6 +384,12 @@ angular.module('portainer.app').controller('StackController', [ }); } + this.uiCanExit = async function () { + if ($scope.stackFileContent && $scope.state.isEditorDirty) { + return ModalService.confirmWebEditorDiscard(); + } + }; + async function initView() { var stackName = $transition$.params().name; $scope.stackName = stackName;