[Password Manager] Add "exporting..." and "error" states to the export passwords dialog
The different states of the export flow are implemented as separate dialog instances
which replace each other.
Mocks:
https://docs.google.com/presentation/d/1nIm5OmaOnb85ZAwMZPSVqHT0vkFbsRYZhOzbmznWU_c/edit#slide=id.g289b1efcd8_0_4
Bug: 789561
Cq-Include-Trybots: master.tryserver.chromium.linux:closure_compilation
Change-Id: Id25af371164196ee8fffec723f080be2320e5b22
Reviewed-on: https://chromium-review.googlesource.com/800611
Reviewed-by: Hector Carmona <hcarmona@chromium.org>
Commit-Queue: Christos Froussios <cfroussios@chromium.org>
Cr-Commit-Position: refs/heads/master@{#532811}
diff --git a/chrome/app/settings_strings.grdp b/chrome/app/settings_strings.grdp
index abbbdf5..49e775c3 100644
--- a/chrome/app/settings_strings.grdp
+++ b/chrome/app/settings_strings.grdp
@@ -620,6 +620,25 @@
<message name="IDS_SETTINGS_PASSWORDS_EXPORT" desc="A button in the dialog for exporting passwords from Chrome. A password list will be written to a destination, which the user will be asked to choose after initiating this action.">
Export passwords...
</message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORT_TRY_AGAIN" desc="A button in the dialog for exporting passwords from Chrome. The action is to attempt to export the passwords again. A password list will be written to a destination, which the user will be asked to choose after initiating this action.">
+ Try again...
+ </message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_TITLE" desc="The title of a dialog for exporting passwords. This title is shown while Chrome is performing the export and the user should wait.">
+ Exporting passwords...
+ </message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TITLE" desc="The title to a dialog, which is shown if exporting passwords to a folder has failed.">
+ Can't export passwords to "<ph name="FOLDER">$1<ex>Documents</ex></ph>"
+ </message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIPS" desc="Message that is shown when exporting passwords has failed. Below it is a list of things the user can try to resolve the problem.">
+ Try the following tips
+ </message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ENOUGH_SPACE" desc="Message that is shown when exporting passwords has failed. This is part of a list of things the user can try to resolve the problem. This advice implies that there isn't enough space on the disk for the new file to be written.">
+ Make sure there is enough space on your device
+ </message>
+ <message name="IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ANOTHER_FOLDER" desc="Message that is shown when exporting passwords has failed. This is part of a list of things the user can try to resolve the problem. This advice implies that Chrome couldn't write into the specified folder.">
+ Export your passwords to another folder
+ </message>
+
<!-- Default Browser Page -->
<if expr="not chromeos">
diff --git a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
index 9353ae7e..40e3a155 100644
--- a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
+++ b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.html
@@ -8,8 +8,12 @@
<dom-module id="passwords-export-dialog">
<template>
<style include="settings-shared iron-flex">
+ paper-progress {
+ width: 100%;
+ --paper-progress-active-color: var(--google-blue-500);
+ }
</style>
- <dialog is="cr-dialog" id="dialog" close-text="$i18n{close}">
+ <dialog is="cr-dialog" id="dialog_start" close-text="$i18n{close}">
<div slot="title">$i18n{exportPasswordsTitle}</div>
<div slot="body">
<div class="layout horizontal center">
@@ -27,6 +31,41 @@
</paper-button>
</div>
</dialog>
+
+ <dialog is="cr-dialog" id="dialog_progress" close-text="$i18n{close}">
+ <div slot="title">$i18n{exportingPasswordsTitle}</div>
+ <div slot="body">
+ <paper-progress indeterminate class="blue"></paper-progress>
+ </div>
+ <div slot="button-container">
+ <paper-button class="secondary-button header-aligned-button"
+ on-tap="onCancelButtonTap_">
+ $i18n{cancel}
+ </paper-button>
+ </div>
+ </dialog>
+
+ <dialog is="cr-dialog" id="dialog_error" close-text="$i18n{close}">
+ <div slot="title">[[exportErrorMessage]]</div>
+ <div slot="body">
+ $i18n{exportPasswordsFailTips}
+ <ul>
+ <li>$i18n{exportPasswordsFailTipsEnoughSpace}</li>
+ <li>$i18n{exportPasswordsFailTipsAnotherFolder}</li>
+ </ul>
+ </div>
+ <div slot="button-container">
+ <paper-button class="secondary-button header-aligned-button"
+ on-tap="onCancelButtonTap_">
+ $i18n{cancel}
+ </paper-button>
+ <paper-button class="action-button header-aligned-button"
+ on-tap="onExportTap_">
+ $i18n{exportPasswordsTryAgain}
+ </paper-button>
+ </div>
+ </dialog>
+
</template>
<script src="passwords_export_dialog.js"></script>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
index 9730cb04..3a1d087 100644
--- a/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
+++ b/chrome/browser/resources/settings/passwords_and_forms_page/passwords_export_dialog.js
@@ -10,9 +10,43 @@
(function() {
'use strict';
+/**
+ * The states of the export passwords dialog.
+ * @enum {string}
+ */
+const States = {
+ START: 'START',
+ IN_PROGRESS: 'IN_PROGRESS',
+ ERROR: 'ERROR',
+};
+
+const ProgressStatus = chrome.passwordsPrivate.ExportProgressStatus;
+
+/**
+ * The amount of time (ms) between the start of the export and the moment we
+ * start showing the progress bar.
+ * @type {number}
+ */
+const progressBarDelayMs = 100;
+
+/**
+ * The minimum amount of time (ms) that the progress bar will be visible.
+ * @type {number}
+ */
+const progressBarBlockMs = 2000;
+
Polymer({
is: 'passwords-export-dialog',
+ behaviors: [I18nBehavior],
+
+ properties: {
+ /** The error that occurred while exporting. */
+ exportErrorMessage: String,
+ },
+
+ listeners: {'cancel': 'close'},
+
/**
* The interface for callbacks to the browser.
* Defined in passwords_section.js
@@ -21,16 +55,114 @@
*/
passwordManager_: null,
+ /** @private {function(!PasswordManager.PasswordExportProgress):void} */
+ onPasswordsFileExportProgressListener_: null,
+
+ /**
+ * The task that will display the progress bar, if the export doesn't finish
+ * quickly. This is null, unless the task is currently scheduled.
+ * @private {?number}
+ */
+ progressTaskToken_: null,
+
+ /**
+ * The task that will display the completion of the export, if any. We display
+ * the progress bar for at least 2000ms, therefore, if export finishes
+ * earlier, we cache the result in |delayedProgress_| and this task will
+ * consume it. This is null, unless the task is currently scheduled.
+ * @private {?number}
+ */
+ delayedCompletionToken_: null,
+
+ /**
+ * We display the progress bar for at least 2000ms. If progress is achieved
+ * earlier, we store the update here and consume it later.
+ * @private {?PasswordManager.PasswordExportProgress}
+ */
+ delayedProgress_: null,
+
/** @override */
attached: function() {
- this.$.dialog.showModal();
-
this.passwordManager_ = PasswordManagerImpl.getInstance();
+
+ this.switchToDialog_(States.START);
+
+ this.onPasswordsFileExportProgressListener_ =
+ this.onPasswordsFileExportProgress_.bind(this);
+
+ // If export started on a different tab and is still in progress, display a
+ // busy UI.
+ this.passwordManager_.requestExportProgressStatus(status => {
+ if (status == ProgressStatus.IN_PROGRESS)
+ this.switchToDialog_(States.IN_PROGRESS);
+ });
+
+ this.passwordManager_.addPasswordsFileExportProgressListener(
+ this.onPasswordsFileExportProgressListener_);
+ },
+
+ /**
+ * Handles an export progress event by changing the visible dialog or caching
+ * the event for later consumption.
+ * @param {!PasswordManager.PasswordExportProgress} progress
+ * @private
+ */
+ onPasswordsFileExportProgress_(progress) {
+ // If Chrome has already started displaying the progress bar
+ // (|progressTaskToken_ is null) and hasn't completed its minimum display
+ // time (|delayedCompletionToken_| is not null) progress should be cached
+ // for consumption when the blocking time ends.
+ const progressBlocked =
+ !this.progressTaskToken_ && !!this.delayedCompletionToken_;
+ if (!progressBlocked) {
+ clearTimeout(this.progressTaskToken_);
+ this.progressTaskToken_ = null;
+ this.processProgress_(progress);
+ } else {
+ this.delayedProgress_ = progress;
+ }
+ },
+
+ /**
+ * Displays the progress bar and suspends further UI updates for
+ * |progressBarBlockMs|.
+ * @private
+ */
+ progressTask_() {
+ this.progressTaskToken_ = null;
+ this.switchToDialog_(States.IN_PROGRESS);
+
+ this.delayedCompletionToken_ =
+ setTimeout(this.delayedCompletionTask_.bind(this), progressBarBlockMs);
+ },
+
+ /**
+ * Unblocks progress after showing the progress bar for |progressBarBlock|ms
+ * and processes any progress that was delayed.
+ * @private
+ */
+ delayedCompletionTask_() {
+ this.delayedCompletionToken_ = null;
+ if (this.delayedProgress_) {
+ this.processProgress_(this.delayedProgress_);
+ this.delayedProgress_ = null;
+ }
},
/** Closes the dialog. */
close: function() {
- this.$.dialog.close();
+ clearTimeout(this.progressTaskToken_);
+ clearTimeout(this.delayedCompletionToken_);
+ this.progressTaskToken_ = null;
+ this.delayedCompletionToken_ = null;
+ this.passwordManager_.removePasswordsFileExportProgressListener(
+ this.onPasswordsFileExportProgressListener_);
+ if (this.$.dialog_start.open)
+ this.$.dialog_start.close();
+ if (this.$.dialog_progress.open)
+ this.$.dialog_progress.close();
+ if (this.$.dialog_error.open)
+ this.$.dialog_error.close();
},
/**
@@ -38,15 +170,56 @@
* @private
*/
onExportTap_: function() {
- this.passwordManager_.exportPasswords(this.onExportRequested_);
+ this.passwordManager_.exportPasswords(() => {
+ if (chrome.runtime.lastError &&
+ chrome.runtime.lastError.message == 'in-progress') {
+ // Exporting was started by a different call to exportPasswords() and is
+ // is still in progress. This UI needs to be updated to the current
+ // status.
+ this.switchToDialog_(States.IN_PROGRESS);
+ }
+ });
},
/**
- * Callback to let us know whether our request for exporting was accepted.
+ * Prepares and displays the appropriate view (with delay, if necessary).
+ * @param {!PasswordManager.PasswordExportProgress} progress
* @private
*/
- onExportRequested_: function(accepted) {
- // TODO(http://crbug/789561) Jump to "export in progress" UI.
+ processProgress_(progress) {
+ if (progress.status == ProgressStatus.IN_PROGRESS) {
+ this.progressTaskToken_ =
+ setTimeout(this.progressTask_.bind(this), progressBarDelayMs);
+ return;
+ }
+ if (progress.status == ProgressStatus.SUCCEEDED) {
+ this.close();
+ return;
+ }
+ if (progress.status == ProgressStatus.FAILED_WRITE_FAILED) {
+ this.exportErrorMessage =
+ this.i18n('exportPasswordsFailTitle', progress.folderName);
+ this.switchToDialog_(States.ERROR);
+ return;
+ }
+ },
+
+ /**
+ * Opens the specified dialog and hides the others.
+ * @param {!States} state the dialog to open.
+ * @private
+ */
+ switchToDialog_(state) {
+ this.$.dialog_start.open = false;
+ this.$.dialog_error.open = false;
+ this.$.dialog_progress.open = false;
+
+ if (state == States.START)
+ this.$.dialog_start.showModal();
+ if (state == States.ERROR)
+ this.$.dialog_error.showModal();
+ if (state == States.IN_PROGRESS)
+ this.$.dialog_progress.showModal();
},
/**
diff --git a/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc b/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
index f9bfd0f..cff34e16 100644
--- a/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
+++ b/chrome/browser/ui/webui/settings/md_settings_localized_strings_provider.cc
@@ -1304,7 +1304,17 @@
{"passwordDeleted", IDS_SETTINGS_PASSWORD_DELETED_PASSWORD},
{"exportPasswordsTitle", IDS_SETTINGS_PASSWORDS_EXPORT_TITLE},
{"exportPasswordsDescription", IDS_SETTINGS_PASSWORDS_EXPORT_DESCRIPTION},
- {"exportPasswords", IDS_SETTINGS_PASSWORDS_EXPORT}};
+ {"exportPasswords", IDS_SETTINGS_PASSWORDS_EXPORT},
+ {"exportingPasswordsTitle", IDS_SETTINGS_PASSWORDS_EXPORTING_TITLE},
+ {"exportPasswordsTryAgain", IDS_SETTINGS_PASSWORDS_EXPORT_TRY_AGAIN},
+ {"exportPasswordsFailTitle",
+ IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TITLE},
+ {"exportPasswordsFailTips",
+ IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIPS},
+ {"exportPasswordsFailTipsEnoughSpace",
+ IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ENOUGH_SPACE},
+ {"exportPasswordsFailTipsAnotherFolder",
+ IDS_SETTINGS_PASSWORDS_EXPORTING_FAILURE_TIP_ANOTHER_FOLDER}};
html_source->AddString(
"managePasswordsLabel",
diff --git a/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js b/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
index ee43d9f..8227f976 100644
--- a/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
+++ b/chrome/test/data/webui/settings/settings_passwords_section_browsertest.js
@@ -15,6 +15,9 @@
GEN_INCLUDE([ROOT_PATH +
'chrome/test/data/webui/settings/passwords_and_autofill_fake_data.js']);
+// Mock timer.
+GEN_INCLUDE([ROOT_PATH + 'chrome/test/data/webui/mock_timer.js']);
+
/**
* @constructor
* @extends {PolymerTest}
@@ -169,7 +172,18 @@
* @return {!Object}
* @private
*/
- function createExportPasswordsDialog() {
+ function createExportPasswordsDialog(passwordManager) {
+ passwordManager.requestExportProgressStatus = callback => {
+ callback(chrome.passwordsPrivate.ExportProgressStatus.NOT_STARTED);
+ };
+ passwordManager.addPasswordsFileExportProgressListener = callback => {
+ passwordManager.progressCallback = callback;
+ };
+ passwordManager.removePasswordsFileExportProgressListener = () => {};
+ passwordManager.exportPasswords = (callback) => {
+ callback();
+ };
+
const dialog = document.createElement('passwords-export-dialog');
document.body.appendChild(dialog);
Polymer.dom.flush();
@@ -571,17 +585,6 @@
MockInteractions.tap(passwordListItem.$$('#showPasswordButton'));
});
- // Test that tapping "Export passwords..." notifies the browser accordingly
- test('startExport', function(done) {
- const exportDialog = createExportPasswordsDialog();
-
- passwordManager.exportPasswords = () => {
- done();
- };
-
- MockInteractions.tap(exportDialog.$.exportPasswordsButton);
- });
-
test('closingPasswordsSectionHidesUndoToast', function(done) {
const passwordEntry = FakeDataMaker.passwordEntry('goo.gl', 'bart', 1);
const passwordsSection =
@@ -602,6 +605,113 @@
done();
});
+
+ // Test that tapping "Export passwords..." notifies the browser accordingly
+ test('startExport', function(done) {
+ const exportDialog = createExportPasswordsDialog(passwordManager);
+
+ passwordManager.exportPasswords = (callback) => {
+ callback();
+ done();
+ };
+
+ MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+ });
+
+ // Test the export flow. If exporting is fast, we should skip the
+ // in-progress view altogether.
+ test('exportFlowFast', function(done) {
+ const exportDialog = createExportPasswordsDialog(passwordManager);
+ const progressCallback = passwordManager.progressCallback;
+
+ // Use this to freeze the delayed progress bar and avoid flakiness.
+ let mockTimer = new MockTimer();
+ mockTimer.install();
+
+ assertTrue(exportDialog.$.dialog_start.open);
+ MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+ assertTrue(exportDialog.$.dialog_start.open);
+ progressCallback(
+ {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+ progressCallback(
+ {status: chrome.passwordsPrivate.ExportProgressStatus.SUCCEEDED});
+
+ // When we are done, the export dialog closes completely.
+ assertFalse(exportDialog.$.dialog_start.open);
+ assertFalse(exportDialog.$.dialog_error.open);
+ assertFalse(exportDialog.$.dialog_progress.open);
+ done();
+
+ mockTimer.uninstall();
+ });
+
+ // The error view is shown when an error occurs.
+ test('exportFlowError', function(done) {
+ const exportDialog = createExportPasswordsDialog(passwordManager);
+ const progressCallback = passwordManager.progressCallback;
+
+ // Use this to freeze the delayed progress bar and avoid flakiness.
+ let mockTimer = new MockTimer();
+ mockTimer.install();
+
+ assertTrue(exportDialog.$.dialog_start.open);
+ MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+ assertTrue(exportDialog.$.dialog_start.open);
+ progressCallback(
+ {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+ progressCallback({
+ status:
+ chrome.passwordsPrivate.ExportProgressStatus.FAILED_WRITE_FAILED,
+ folderName: 'tmp',
+ });
+
+ assertTrue(exportDialog.$.dialog_error.open);
+ done();
+
+ mockTimer.uninstall();
+ });
+
+ // Test the export flow. If exporting is slow, Chrome should show the
+ // in-progress dialog for at least 2000ms.
+ test('exportFlowSlow', function(done) {
+ const exportDialog = createExportPasswordsDialog(passwordManager);
+ const progressCallback = passwordManager.progressCallback;
+
+ let mockTimer = new MockTimer();
+ mockTimer.install();
+
+ // The initial dialog remains open for 100ms after export enters the
+ // in-progress state.
+ assertTrue(exportDialog.$.dialog_start.open);
+ MockInteractions.tap(exportDialog.$.exportPasswordsButton);
+ assertTrue(exportDialog.$.dialog_start.open);
+ progressCallback(
+ {status: chrome.passwordsPrivate.ExportProgressStatus.IN_PROGRESS});
+ assertTrue(exportDialog.$.dialog_start.open);
+
+ // After 100ms of not having completed, the dialog switches to the
+ // progress bar. Chrome will continue to show the progress bar for 2000ms,
+ // despite a completion event.
+ mockTimer.tick(99);
+ assertTrue(exportDialog.$.dialog_start.open);
+ mockTimer.tick(1);
+ assertTrue(exportDialog.$.dialog_progress.open);
+ progressCallback(
+ {status: chrome.passwordsPrivate.ExportProgressStatus.SUCCEEDED});
+ assertTrue(exportDialog.$.dialog_progress.open);
+
+ // After 2000ms, Chrome will display the completion event.
+ mockTimer.tick(1999);
+ assertTrue(exportDialog.$.dialog_progress.open);
+ mockTimer.tick(1);
+ // On SUCCEEDED the dialog closes completely.
+ assertFalse(exportDialog.$.dialog_progress.open);
+ assertFalse(exportDialog.$.dialog_start.open);
+ assertFalse(exportDialog.$.dialog_error.open);
+ done();
+
+ mockTimer.uninstall();
+ });
});
mocha.run();