commit b63cca551be4eff43597ae95edc0435e376b7e3c
parent 07a1fec17a14c4cd7a429dd3af68c9fd20c370ea
Author: negin <nsauermann@mozilla.com>
Date: Thu, 2 Oct 2025 14:22:10 +0000
Bug 1986470 - [Fx backup messaging] Disable secondary "Don't Restore" button on Backup Screen when backup in progress and renable if there is an error in backing up r=omc-reviewers,mconley,jprickett
Adds secondary CTA to the Embedded Backup AW screen. Secondary CTA is disabled when there is a backup in progress. If there's an error, CTA should be re-enabled.
[figma](https://www.figma.com/design/vNbX4c0ws0L1qr0mxpKvsW/Fx-Backup?node-id=5886-68187&p=f&m=dev)
Differential Revision: https://phabricator.services.mozilla.com/D263767
Diffstat:
7 files changed, 315 insertions(+), 103 deletions(-)
diff --git a/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx b/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx
@@ -302,6 +302,7 @@ export const ContentTiles = props => {
<EmbeddedBackupRestore
handleAction={props.handleAction}
content={{ tiles: tile }}
+ skipButton={props.content.skip_button}
/>
)}
</div>
diff --git a/browser/components/aboutwelcome/content-src/components/EmbeddedBackupRestore.jsx b/browser/components/aboutwelcome/content-src/components/EmbeddedBackupRestore.jsx
@@ -2,28 +2,70 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
-import React, { useRef, useEffect } from "react";
+import React, { useRef, useEffect, useState, useCallback } from "react";
import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs";
+import { Localized } from "./MSLocalized";
-export const EmbeddedBackupRestore = () => {
- const ref = useRef();
+export const EmbeddedBackupRestore = ({ handleAction, skipButton }) => {
+ const [recoveryInProgress, setRecoveryInProgress] = useState(false);
+ const ref = useRef(null);
useEffect(() => {
// Clear the pref used to target the restore screen so that users will not
// automatically see it again the next time they visit about:welcome.
AboutWelcomeUtils.handleUserAction({
type: "SET_PREF",
- data: {
- pref: { name: "showRestoreFromBackup", value: false },
- },
+ data: { pref: { name: "showRestoreFromBackup", value: false } },
});
}, []);
+ const onRecoveryProgressChange = useCallback(e => {
+ setRecoveryInProgress(e.detail.recoveryInProgress);
+ }, []);
+
+ useEffect(() => {
+ const backupRef = ref.current;
+
+ if (backupRef.backupServiceState) {
+ setRecoveryInProgress(backupRef.backupServiceState.recoveryInProgress);
+ }
+
+ backupRef.addEventListener(
+ "BackupUI:RecoveryProgress",
+ onRecoveryProgressChange
+ );
+
+ return () => {
+ backupRef.removeEventListener(
+ "BackupUI:RecoveryProgress",
+ onRecoveryProgressChange
+ );
+ };
+ }, [onRecoveryProgressChange]);
+
return (
- <restore-from-backup
- aboutWelcomeEmbedded="true"
- labelFontWeight="600"
- ref={ref}
- ></restore-from-backup>
+ <div className="embedded-backup-restore-container">
+ <restore-from-backup
+ aboutWelcomeEmbedded="true"
+ labelFontWeight="600"
+ ref={ref}
+ />
+ {skipButton ? (
+ <div className="action-buttons">
+ <div className="secondary-cta">
+ <Localized text={skipButton.label}>
+ <button
+ id="secondary_button"
+ className="secondary"
+ value="skip_button"
+ disabled={recoveryInProgress}
+ aria-busy={recoveryInProgress || undefined}
+ onClick={handleAction}
+ />
+ </Localized>
+ </div>
+ </div>
+ ) : null}
+ </div>
);
};
diff --git a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js
@@ -2489,7 +2489,8 @@ const ContentTiles = props => {
handleAction: props.handleAction,
content: {
tiles: tile
- }
+ },
+ skipButton: props.content.skip_button
})) : null);
};
const renderContentTiles = () => {
@@ -3452,14 +3453,20 @@ __webpack_require__.r(__webpack_exports__);
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
/* harmony import */ var _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3);
+/* harmony import */ var _MSLocalized__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(5);
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
-const EmbeddedBackupRestore = () => {
- const ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)();
+
+const EmbeddedBackupRestore = ({
+ handleAction,
+ skipButton
+}) => {
+ const [recoveryInProgress, setRecoveryInProgress] = (0,react__WEBPACK_IMPORTED_MODULE_0__.useState)(false);
+ const ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(null);
(0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => {
// Clear the pref used to target the restore screen so that users will not
// automatically see it again the next time they visit about:welcome.
@@ -3473,11 +3480,39 @@ const EmbeddedBackupRestore = () => {
}
});
}, []);
- return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("restore-from-backup", {
+ const onRecoveryProgressChange = (0,react__WEBPACK_IMPORTED_MODULE_0__.useCallback)(e => {
+ setRecoveryInProgress(e.detail.recoveryInProgress);
+ }, []);
+ (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => {
+ const backupRef = ref.current;
+ if (backupRef.backupServiceState) {
+ setRecoveryInProgress(backupRef.backupServiceState.recoveryInProgress);
+ }
+ backupRef.addEventListener("BackupUI:RecoveryProgress", onRecoveryProgressChange);
+ return () => {
+ backupRef.removeEventListener("BackupUI:RecoveryProgress", onRecoveryProgressChange);
+ };
+ }, [onRecoveryProgressChange]);
+ return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
+ className: "embedded-backup-restore-container"
+ }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("restore-from-backup", {
aboutWelcomeEmbedded: "true",
labelFontWeight: "600",
ref: ref
- });
+ }), skipButton ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
+ className: "action-buttons"
+ }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
+ className: "secondary-cta"
+ }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_2__.Localized, {
+ text: skipButton.label
+ }, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
+ id: "secondary_button",
+ className: "secondary",
+ value: "skip_button",
+ disabled: recoveryInProgress,
+ "aria-busy": recoveryInProgress || undefined,
+ onClick: handleAction
+ })))) : null);
};
/***/ }),
diff --git a/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx b/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx
@@ -900,98 +900,129 @@ describe("ContentTiles component", () => {
);
wrapper.unmount();
});
-});
-it("restores last tiles focus in Spotlight context and genuine Tab is ignored", async () => {
- const TAB_GRACE_WINDOW_MS = 250;
-
- function nextFrame() {
- return new Promise(r => requestAnimationFrame(r));
- }
- function delay(ms) {
- return new Promise(r => setTimeout(r, ms));
- }
- async function waitFor(condition, timeout = TAB_GRACE_WINDOW_MS) {
- const start = performance.now();
- while (!condition()) {
- await nextFrame();
- if (performance.now() - start > timeout) {
- throw new Error("timeout waiting for condition");
+ it("restores last tiles focus in Spotlight context and genuine Tab is ignored", async () => {
+ const TAB_GRACE_WINDOW_MS = 250;
+
+ function nextFrame() {
+ return new Promise(r => requestAnimationFrame(r));
+ }
+ function delay(ms) {
+ return new Promise(r => setTimeout(r, ms));
+ }
+ async function waitFor(condition, timeout = TAB_GRACE_WINDOW_MS) {
+ const start = performance.now();
+ while (!condition()) {
+ await nextFrame();
+ if (performance.now() - start > timeout) {
+ throw new Error("timeout waiting for condition");
+ }
}
}
- }
- // Pretend we're in a Spotlight dialog so the effect runs
- const root = document.body.appendChild(document.createElement("div"));
- root.id = "multi-stage-message-root";
- root.className = "onboardingContainer";
- root.dataset.page = "spotlight";
+ // Pretend we're in a Spotlight dialog so the effect runs
+ const root = document.body.appendChild(document.createElement("div"));
+ root.id = "multi-stage-message-root";
+ root.className = "onboardingContainer";
+ root.dataset.page = "spotlight";
- const mountNode = document.body.appendChild(document.createElement("div"));
+ const mountNode = document.body.appendChild(document.createElement("div"));
- const content = {
- tiles: [{ type: "multiselect", header: { title: "Test" }, data: [] }],
- };
+ const content = {
+ tiles: [{ type: "multiselect", header: { title: "Test" }, data: [] }],
+ };
+
+ const focusWrapper = mount(
+ <main role="alertdialog">
+ <ContentTiles
+ content={content}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={() => {}}
+ />
+ <div className="action-buttons">
+ <button className="primary">Continue</button>
+ </div>
+ </main>,
+ { attachTo: mountNode }
+ );
+
+ // Let the hook attach listeners
+ await nextFrame();
+
+ const dialog = focusWrapper.getDOMNode();
+ const header = dialog.querySelector(".tile-header");
+ const primary = dialog.querySelector(".action-buttons .primary");
+
+ // Record real DOM focus inside tiles
+ header.focus();
+ header.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
+ await nextFrame();
+
+ // Wait past the tab grace window so this isn’t treated as a real Tab
+ await delay(TAB_GRACE_WINDOW_MS + 1);
+
+ // Simulate programmatic focus “snap” to an outside control
+ primary.focus();
+ primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
+
+ await waitFor(() => document.activeElement === header);
+ assert.strictEqual(
+ document.activeElement,
+ header,
+ "restored focus to tiles header"
+ );
+
+ dialog.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Tab",
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+ primary.focus();
+ primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
+ await nextFrame();
+ assert.strictEqual(
+ document.activeElement,
+ primary,
+ "did not override genuine Tab focus"
+ );
- const wrapper = mount(
- <main role="alertdialog">
+ // Cleanup
+ focusWrapper.unmount();
+ mountNode.remove();
+ root.remove();
+ });
+
+ it("passes content.skip_button to EmbeddedBackupRestore as skipButton", () => {
+ const content = {
+ tiles: [EMBEDDED_BACKUP_RESTORE_TILE],
+ skip_button: {
+ label: { raw: "Don't restore" },
+ action: { navigate: true },
+ },
+ };
+
+ const mountedWrapper = mount(
<ContentTiles
content={content}
- handleAction={() => {}}
+ handleAction={handleAction}
activeMultiSelect={null}
- setActiveMultiSelect={() => {}}
+ setActiveMultiSelect={setActiveMultiSelect}
/>
- <div className="action-buttons">
- <button className="primary">Continue</button>
- </div>
- </main>,
- { attachTo: mountNode }
- );
-
- // Let the hook attach listeners
- await nextFrame();
-
- const dialog = wrapper.getDOMNode();
- const header = dialog.querySelector(".tile-header");
- const primary = dialog.querySelector(".action-buttons .primary");
-
- // Record real DOM focus inside tiles
- header.focus();
- header.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
- await nextFrame();
-
- // Wait past the tab grace window so this isn’t treated as a real Tab
- await delay(TAB_GRACE_WINDOW_MS + 1);
-
- // Simulate programmatic focus “snap” to an outside control
- primary.focus();
- primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
-
- await waitFor(() => document.activeElement === header);
- assert.strictEqual(
- document.activeElement,
- header,
- "restored focus to tiles header"
- );
-
- dialog.dispatchEvent(
- new KeyboardEvent("keydown", {
- key: "Tab",
- bubbles: true,
- cancelable: true,
- })
- );
- primary.focus();
- primary.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
- await nextFrame();
- assert.strictEqual(
- document.activeElement,
- primary,
- "did not override genuine Tab focus"
- );
-
- // Cleanup
- wrapper.unmount();
- mountNode.remove();
- root.remove();
+ );
+
+ const embeddedBackupComponent = mountedWrapper.find(EmbeddedBackupRestore);
+ assert.ok(
+ embeddedBackupComponent.exists(),
+ "EmbeddedBackupRestore rendered"
+ );
+ assert.deepEqual(
+ embeddedBackupComponent.prop("skipButton"),
+ content.skip_button,
+ "prop is wired"
+ );
+ mountedWrapper.unmount();
+ });
});
diff --git a/browser/components/backup/content/restore-from-backup.css b/browser/components/backup/content/restore-from-backup.css
@@ -93,6 +93,10 @@
font-weight: var(--label-font-weight, normal);
}
+ & > #backup-password-error {
+ text-align: start;
+ }
+
& > #backup-password-description {
color: var(--text-color-deemphasized);
font-size: var(--font-size-small);
diff --git a/browser/components/backup/content/restore-from-backup.mjs b/browser/components/backup/content/restore-from-backup.mjs
@@ -58,7 +58,7 @@ export default class RestoreFromBackup extends MozLitElement {
supportBaseLink: "",
backupInProgress: false,
recoveryInProgress: false,
- recoveryErrorCode: 0,
+ recoveryErrorCode: ERRORS.NONE,
};
}
@@ -91,6 +91,23 @@ export default class RestoreFromBackup extends MozLitElement {
}
}
+ updated(changedProperties) {
+ if (changedProperties.has("backupServiceState")) {
+ // If we got a recovery error, recoveryInProgress should be false
+ const inProgress =
+ this.backupServiceState.recoveryInProgress &&
+ !this.backupServiceState.recoveryErrorCode;
+
+ this.dispatchEvent(
+ new CustomEvent("BackupUI:RecoveryProgress", {
+ bubbles: true,
+ composed: true,
+ detail: { recoveryInProgress: inProgress },
+ })
+ );
+ }
+ }
+
async handleChooseBackupFile() {
this.dispatchEvent(
new CustomEvent("BackupUI:ShowFilepicker", {
@@ -236,10 +253,10 @@ export default class RestoreFromBackup extends MozLitElement {
<span
id="backup-password-error"
class="field-error"
- data-l10n-id="restore-from-backup-error-incorrect-password"
+ data-l10n-id="backup-service-error-incorrect-password"
>
<a
- id="restore-from-backup-incorrect-password-support-link"
+ id="backup-incorrect-password-support-link"
slot="support-link"
is="moz-support-link"
support-page="todo-backup"
diff --git a/browser/components/backup/tests/chrome/test_restore_from_backup.html b/browser/components/backup/tests/chrome/test_restore_from_backup.html
@@ -146,7 +146,7 @@
add_task(async function test_incorrect_password_error_condition() {
let restoreFromBackup = document.getElementById("test-restore-from-backup");
- is(restoreFromBackup.backupServiceState.recoveryErrorCode, 0, "Recovery error code should be 0");
+ is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.NONE, "Recovery error code should be 0");
ok(!restoreFromBackup.isIncorrectPassword, "Error message should not be displayed");
ok(!restoreFromBackup.errorMessageEl, "No error message should be displayed");
@@ -180,6 +180,88 @@
is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.CORRUPTED_ARCHIVE, "Recovery error code should be set");
ok(restoreFromBackup.errorMessageEl, "Error message should be displayed");
});
+
+ /**
+ * Tests that changes to backupServiceState emits BackupUI:RecoveryProgress,
+ * with the current progress state and progress is false when an error code is present.
+ */
+ add_task(async function test_recovery_state_updates() {
+ const content = document.getElementById("content");
+ let restoreFromBackup = document.getElementById("test-restore-from-backup");
+ content.appendChild(restoreFromBackup);
+
+ // Reset previous state changes
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ recoveryInProgress: false,
+ recoveryErrorCode: ERRORS.NONE,
+ };
+ await restoreFromBackup.updateComplete;
+
+ // Helper to dispatch the BackupUI:RecoveryProgress event
+ async function sendState(testState) {
+ const promise = BrowserTestUtils.waitForEvent(
+ restoreFromBackup,
+ "BackupUI:RecoveryProgress"
+ );
+ restoreFromBackup.backupServiceState = {
+ ...restoreFromBackup.backupServiceState,
+ ...testState,
+ };
+ await restoreFromBackup.updateComplete;
+ return promise;
+ }
+
+ // Initial state
+ is(
+ restoreFromBackup.backupServiceState.recoveryInProgress,
+ false,
+ "Initial progress state is false"
+ );
+ is(restoreFromBackup.backupServiceState.recoveryErrorCode, ERRORS.NONE, "Initial error code should be 0");
+
+ // Backup in progress with no error
+ let event = await sendState({ recoveryInProgress: true });
+ is(event.detail?.recoveryInProgress, true, "'recoveryInProgress' is true");
+ is(restoreFromBackup.backupServiceState.recoveryInProgress, true, "State reflects in-progress");
+
+ // Backup not in progress
+ event = await sendState({ recoveryInProgress: false, recoveryErrorCode: 0 });
+ is(event.detail?.recoveryInProgress, false, "'recoveryInProgress' is false");
+ is(
+ restoreFromBackup.backupServiceState.recoveryInProgress,
+ false,
+ "State reflects not in-progress"
+ );
+
+ // Any error should clear progress
+ for (const code of [ERRORS.CORRUPTED_ARCHIVE, ERRORS.UNAUTHORIZED]) {
+ info(`Asserting recovery progress clears with error code: ${code}`);
+ event = await sendState({
+ recoveryInProgress: true,
+ recoveryErrorCode: ERRORS.NONE,
+ });
+ is(
+ restoreFromBackup.backupServiceState.recoveryInProgress,
+ true,
+ "State reflects in-progress"
+ );
+
+ // Add an error
+ event = await sendState({
+ recoveryInProgress: true,
+ recoveryErrorCode: code,
+ });
+ is(
+ event.detail?.recoveryInProgress,
+ false,
+ `Progress cleared for error ${code}`
+ );
+ // Clear state
+ await sendState({ recoveryInProgress: false, recoveryErrorCode: ERRORS.NONE });
+ }
+ restoreFromBackup.remove();
+ });
</script>
</head>
<body>