From 778e691d504f7035047de415e66620c7cb225ea5 Mon Sep 17 00:00:00 2001 From: Jan Sudczak Date: Tue, 18 Dec 2018 12:31:42 +0100 Subject: [PATCH] Unsaved changes modal (#252) --- client/luigi-client.js | 14 +- .../e2e/tests/luigi-client-features.spec.js | 54 ++++++ .../src/app/overview/overview.component.html | 80 +++++---- .../src/app/overview/overview.component.ts | 7 +- core/src/App.html | 156 ++++++++++++++---- core/src/Authorization.html | 36 ++-- core/src/ConfirmationModal.html | 60 +++++++ core/src/navigation/ContextSwitcher.html | 4 +- core/src/navigation/LeftNav.html | 10 +- core/src/navigation/LogoTitle.html | 8 +- core/src/navigation/TopNav.html | 18 +- core/src/services/routing.js | 37 +++-- core/src/utilities/helpers/generic-helpers.js | 6 + core/test/services/routing.spec.js | 10 +- docs/luigi-client-api.md | 64 +++---- 15 files changed, 425 insertions(+), 139 deletions(-) create mode 100644 core/src/ConfirmationModal.html diff --git a/client/luigi-client.js b/client/luigi-client.js index c350269f16..86bf710473 100644 --- a/client/luigi-client.js +++ b/client/luigi-client.js @@ -245,7 +245,7 @@ return { /** @lends linkManager */ /** - * Navigates to the given path in application hosted by Luigi. It contains either a full absolute path or a relative path without a leading slash that uses the active route as a base. This is the standard navigation. + * Navigates to the given path in the application hosted by Luigi. It contains either a full absolute path or a relative path without a leading slash that uses the active route as a base. This is the standard navigation. * @param {string} path path to be navigated to * @param {string} sessionId current Luigi **sessionId** * @param {boolean} preserveView Preserve a view by setting it to `true`. It keeps the current view opened in the background and opens the new route in a new frame. Use the {@link #goBack goBack()} function to navigate back. You can use this feature across different levels. Preserved views are discarded as soon as the standard {@link #navigate navigate()} function is used instead of {@link #goBack goBack()}. @@ -336,7 +336,7 @@ /** @lends linkManager */ /** - * Checks if the path you can navigate to exists in the main application. For example, you can use this helper method conditionally display a DOM element like a button. + * Checks if the path you can navigate to exists in the main application. For example, you can use this helper method conditionally to display a DOM element like a button. * @param {string} path path which existence you want to check * @returns {promise} A promise which resolves to a Boolean variable specifying whether the path exists or not. * @example @@ -431,6 +431,16 @@ */ removeBackdrop: function removeBackdrop() { window.parent.postMessage({ msg: 'luigi.remove-backdrop' }, '*'); + }, + /** + * This method informs the main application that there are unsaved changes in the current view in the iframe. For example, that can be a view with form fields which were edited but not submitted. + * @param {boolean} isDirty tells if there are any unsaved changes on the current page or component + */ + setDirtyStatus: function setDirtyStatus(isDirty) { + window.parent.postMessage( + { msg: 'luigi.set-page-dirty', dirty: isDirty }, + '*' + ); } }; } diff --git a/core/examples/luigi-sample-angular/e2e/tests/luigi-client-features.spec.js b/core/examples/luigi-sample-angular/e2e/tests/luigi-client-features.spec.js index 120644bb5d..5a3c8eed9a 100644 --- a/core/examples/luigi-sample-angular/e2e/tests/luigi-client-features.spec.js +++ b/core/examples/luigi-sample-angular/e2e/tests/luigi-client-features.spec.js @@ -229,5 +229,59 @@ describe('Luigi client features', () => { cy.get('.spinnerContainer .fd-spinner').should('not.exist'); }); }); + it("Unsaved changes - shouldn't proceed when 'No' was pressed in modal", () => { + cy.get('iframe').then($iframe => { + const $iframeBody = $iframe.contents().find('body'); + + cy.wrap($iframeBody) + .find('[data-cy=toggle-dirty-state]') + .check(); + + cy.get('button') + .contains('Projects') + .click(); + + cy.get('[data-cy=confirmation-modal]').should('be.visible'); + + cy.location().should(loc => { + expect(loc.pathname).to.eq('/overview'); //the location is unchanged + }); + + cy.get('[data-cy=modal-no]').click(); + + cy.get('[data-cy=confirmation-modal]').should('not.be.visible'); + + cy.location().should(loc => { + expect(loc.pathname).to.eq('/overview'); //the location is still unchanged after "No" clicked + }); + }); + }); + it("Unsaved changes - should proceed when 'Yes' was pressed in modal", () => { + cy.get('iframe').then($iframe => { + const $iframeBody = $iframe.contents().find('body'); + + cy.wrap($iframeBody) + .find('[data-cy=toggle-dirty-state]') + .check(); + + cy.get('button') + .contains('Projects') + .click(); + + cy.get('[data-cy=confirmation-modal]').should('be.visible'); + + cy.location().should(loc => { + expect(loc.pathname).to.eq('/overview'); //the location is unchanged + }); + + cy.get('[data-cy=modal-yes]').click(); + + cy.get('[data-cy=confirmation-modal]').should('not.be.visible'); + + cy.location().should(loc => { + expect(loc.pathname).to.eq('/projects'); //the location is changed after "Yes" clicked + }); + }); + }); }); }); diff --git a/core/examples/luigi-sample-angular/src/app/overview/overview.component.html b/core/examples/luigi-sample-angular/src/app/overview/overview.component.html index 9cb18a59a1..73542344e9 100644 --- a/core/examples/luigi-sample-angular/src/app/overview/overview.component.html +++ b/core/examples/luigi-sample-angular/src/app/overview/overview.component.html @@ -1,36 +1,58 @@
-
-
-

- Luigi Angular Example -

-

Follow the links below to the Luigi feature demos.

-
-
+
+
+

+ Luigi Angular Example +

+

Follow the links below to the Luigi feature demos.

+
+
-
-

LuigiClient Features

-
-
-
    -
  • - {{item.text}}  – {{item.description}} -
  • -
-
+
+

LuigiClient Features

+
+
+
    +
  • + {{item.text}}  – {{item.description}} +
  • +
+
-
-

Luigi Core Features

-
-
-
    -
  • - {{item.text}}  – {{item.description}} -
  • -
-
-
+
+

Luigi Core Features

+
+
+ +
+ \ No newline at end of file diff --git a/core/examples/luigi-sample-angular/src/app/overview/overview.component.ts b/core/examples/luigi-sample-angular/src/app/overview/overview.component.ts index 6208b59a6f..1143363de8 100644 --- a/core/examples/luigi-sample-angular/src/app/overview/overview.component.ts +++ b/core/examples/luigi-sample-angular/src/app/overview/overview.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import LuigiClient from '@kyma-project/luigi-client'; @Component({ @@ -57,4 +57,9 @@ export class OverviewComponent { description: 'navigation node configuration to redirect to another path' } ]; + + private isDirty: boolean = false; + private sendDirtyEvent = () => { + LuigiClient.uxManager().setDirtyStatus(this.isDirty); + }; } diff --git a/core/src/App.html b/core/src/App.html index e2b1acfa32..4c41fde0d5 100644 --- a/core/src/App.html +++ b/core/src/App.html @@ -1,25 +1,29 @@
{#if alert && alert.message} - + + {/if} + {#if confirmationModal.isDisplayed} + {/if}
{#if showLoadingIndicator} -
-
-
-
-
+
+
+
+
+
{/if} - + {#if !(hideNav||hideSideNav)} - + {/if}
@@ -28,10 +32,10 @@ import { fade } from 'svelte-transitions'; import TopNav from './navigation/TopNav.html'; import LeftNav from './navigation/LeftNav.html'; - import * as Routing from './services/routing'; + import ConfirmationModal from './ConfirmationModal.html'; + import * as Routing from './services/routing.js'; import * as Iframe from './services/iframe'; import * as RoutingHelpers from './utilities/helpers/routing-helpers'; - import * as GenericHelpers from './utilities/helpers/generic-helpers'; import * as AuthHelpers from './utilities/helpers/auth-helpers'; @@ -180,6 +184,16 @@ // iframe: Element // } ], + confirmationModal: { + isDisplayed: false, + title: null, + text: null, + promise: null + }, + unsavedChanges: { + isDirty: false, + persistUrl: null + }, hideSideNav: false }; }, @@ -221,30 +235,35 @@ if ('luigi.navigation.open' === e.data.msg) { this.set({ isNavigateBack: false }); - handleNavigation(this, e.data, config); + this.getUnsavedChangesModalPromise().then( + () => { handleNavigation(this, e.data, config); } + ); } if ('luigi.navigation.back' === e.data.msg) { // go back: context from the view const preservedViews = this.get().preservedViews; if (preservedViews && preservedViews.length) { - // remove current active iframe and data - Iframe.setActiveIframeToPrevious(node); - const previousActiveIframeData = preservedViews.pop(); - // set new active iframe and preservedViews - config.iframe = Iframe.getActiveIframe(node); - this.set({ - isNavigateBack: true, - preservedViews: preservedViews, - goBackContext: - e.data.goBackContext && JSON.parse(e.data.goBackContext) - }); - - // TODO: check if handleNavigation or history pop to update hash / path - handleNavigation( - this, - { params: { link: previousActiveIframeData.path } }, - config + this.getUnsavedChangesModalPromise().then( + () => { + // remove current active iframe and data + Iframe.setActiveIframeToPrevious(node); + const previousActiveIframeData = preservedViews.pop(); + // set new active iframe and preservedViews + config.iframe = Iframe.getActiveIframe(node); + this.set({ + isNavigateBack: true, + preservedViews, + goBackContext: + e.data.goBackContext && JSON.parse(e.data.goBackContext) + }); + // TODO: check if getNavigationPath or history pop to update hash / path + const path = handleNavigation( + this, + {params: { link: previousActiveIframeData.path } }, + config + ); + } ); } else { console.error('goBack() not possible, no preserved views found.'); @@ -275,6 +294,14 @@ '*' ); } + if ('luigi.set-page-dirty' === e.data.msg) { + this.set({ + unsavedChanges: { + isDirty: e.data.dirty || false, + persistUrl: window.location.href + } + }); + } }); // listeners are not automatically removed — cancel @@ -290,12 +317,75 @@ isNavigateBack: this.get().isNavigateBack, viewStackSize: this.get().preservedViews.length }; + }, + handleNavClick(node) { + this.getUnsavedChangesModalPromise().then( + () => { Routing.handleRouteClick(node) } + ); + }, + handleModalResult(result) { + const promise = this.get().confirmationModal.promise; + if (result) { + this.set({ + unsavedChanges: { isDirty: false, persistUrl: null } + }); + promise.resolve(); + } else { + promise.reject(); + } + this.hideModal(); + }, + getUnsavedChangesModalPromise() { + return new Promise(resolve => { + if (this.shouldShowUnsavedChangesModal()) { + this.showUnsavedChangesModal().then( + () => { resolve() }, + () => { } + ); + } else { + resolve(); + } + }) + }, + shouldShowUnsavedChangesModal() { + return GenericHelpers.canComponentHandleModal(this) && this.get().unsavedChanges.isDirty + }, + showUnsavedChangesModal() { + return this.showModal( + 'Unsaved changes detected', + 'It looks like you might lose some data if you leave this page. Are you sure you want to do this?' + ) + }, + showModal(title, text) { + return new Promise((resolve, reject) => { + //send the response when one of following methods were executed + this.set({ + confirmationModal: { + isDisplayed: true, + title, + text, + promise: { resolve, reject } + } + }); + }); + }, + hideModal() { + this.set({ + confirmationModal: { + isDisplayed: false, + title: null, + text: null, + promise: null + } + }); } }, + components: { Backdrop, TopNav, - LeftNav + LeftNav, + ConfirmationModal }, transitions: { fade } }; diff --git a/core/src/Authorization.html b/core/src/Authorization.html index 721110293b..06bf9bc4b1 100644 --- a/core/src/Authorization.html +++ b/core/src/Authorization.html @@ -1,7 +1,7 @@