/* 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/. */ // eslint-disable-next-line import/no-unassigned-import import "chrome://global/content/elements/moz-button-group.mjs"; import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; /** * This component contains the UI that steps users through migrating their * data from other browsers to this one. This component only contains very * basic logic and structure for the UI, and most of the state management * occurs in the MigrationWizardChild JSWindowActor. */ export class MigrationWizard extends HTMLElement { static #template = null; #deck = null; #browserProfileSelector = null; #browserProfileSelectorList = null; #resourceTypeList = null; #shadowRoot = null; #importButton = null; static get markup() { return ` `; } static get fragment() { if (!MigrationWizard.#template) { let parser = new DOMParser(); let doc = parser.parseFromString(MigrationWizard.markup, "text/html"); MigrationWizard.#template = document.importNode( doc.querySelector("template"), true ); } let fragment = MigrationWizard.#template.content.cloneNode(true); if (window.IS_STORYBOOK) { // If we're using Storybook, load the CSS from the static local file // system rather than chrome:// to take advantage of auto-reloading. fragment.querySelector("link[rel=stylesheet]").href = "./migration/migration-wizard.css"; } return fragment; } constructor() { super(); const shadow = this.attachShadow({ mode: "closed" }); if (window.MozXULElement) { window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); window.MozXULElement.insertFTLIfNeeded( "locales-preview/migrationWizard.ftl" ); } document.l10n.connectRoot(shadow); shadow.appendChild(MigrationWizard.fragment); this.#deck = shadow.querySelector("#wizard-deck"); this.#browserProfileSelector = shadow.querySelector( "#browser-profile-selector" ); let cancelCloseButtons = shadow.querySelectorAll(".cancel-close"); for (let button of cancelCloseButtons) { button.addEventListener("click", this); } let doneCloseButtons = shadow.querySelector("#done-button"); doneCloseButtons.addEventListener("click", this); this.#importButton = shadow.querySelector("#import"); this.#importButton.addEventListener("click", this); this.#browserProfileSelector.addEventListener("click", this); this.#resourceTypeList = shadow.querySelector("#resource-type-list"); this.#resourceTypeList.addEventListener("change", this); this.#shadowRoot = shadow; } connectedCallback() { if (this.hasAttribute("auto-request-state")) { this.requestState(); } } requestState() { this.dispatchEvent( new CustomEvent("MigrationWizard:RequestState", { bubbles: true }) ); } /** * This setter can be used in the event that the MigrationWizard is being * inserted via Lit, and the caller wants to set state declaratively using * a property expression. * * @param {object} state * The state object to pass to setState. * @see MigrationWizard.setState. */ set state(state) { this.setState(state); } /** * This is the main entrypoint for updating the state and appearance of * the wizard. * * @param {object} state The state to be represented by the component. * @param {string} state.page The page of the wizard to display. This should * be one of the MigrationWizardConstants.PAGES constants. */ setState(state) { switch (state.page) { case MigrationWizardConstants.PAGES.SELECTION: { this.#onShowingSelection(state); break; } case MigrationWizardConstants.PAGES.PROGRESS: { this.#onShowingProgress(state); break; } } this.#deck.toggleAttribute( "aria-busy", state.page == MigrationWizardConstants.PAGES.LOADING ); this.#deck.setAttribute("selected-view", `page-${state.page}`); if (window.IS_STORYBOOK) { this.#updateForStorybook(); } } #ensureSelectionDropdown() { if (this.#browserProfileSelectorList) { return; } this.#browserProfileSelectorList = this.querySelector("panel-list"); if (!this.#browserProfileSelectorList) { throw new Error( "Could not find a under the MigrationWizard during initialization." ); } this.#browserProfileSelectorList.toggleAttribute( "min-width-from-anchor", true ); this.#browserProfileSelectorList.addEventListener("click", this); } /** * Reacts to changes to the browser / profile selector dropdown. This * should update the list of resource types to match what's supported * by the selected migrator and profile. * * @param {Element} panelItem the selected */ #onBrowserProfileSelectionChanged(panelItem) { this.#browserProfileSelector.selectedPanelItem = panelItem; this.#browserProfileSelector.querySelector(".migrator-name").textContent = panelItem.displayName; this.#browserProfileSelector.querySelector(".profile-name").textContent = panelItem.profile?.name || ""; let resourceTypes = panelItem.resourceTypes; for (let child of this.#resourceTypeList.children) { child.hidden = true; child.control.checked = false; } for (let resourceType of resourceTypes) { let resourceLabel = this.#resourceTypeList.querySelector( `label[data-resource-type="${resourceType}"]` ); if (resourceLabel) { resourceLabel.hidden = false; resourceLabel.control.checked = true; } } let selectAll = this.#shadowRoot.querySelector("#select-all").control; selectAll.checked = true; this.#displaySelectedResources(); this.#browserProfileSelector.selectedPanelItem = panelItem; } /** * Called when showing the browser/profile selection page of the wizard. * * @param {object} state * The state object passed into setState. The following properties are * used: * @param {string[]} state.migrators An array of source browser names that * can be migrated from. */ #onShowingSelection(state) { this.#ensureSelectionDropdown(); this.#browserProfileSelectorList.textContent = ""; let selectionPage = this.#shadowRoot.querySelector( "div[name='page-selection']" ); let details = this.#shadowRoot.querySelector("details"); selectionPage.toggleAttribute("show-import-all", state.showImportAll); details.open = !state.showImportAll; for (let migrator of state.migrators) { let opt = document.createElement("panel-item"); opt.setAttribute("key", migrator.key); opt.profile = migrator.profile; opt.displayName = migrator.displayName; opt.resourceTypes = migrator.resourceTypes; if (migrator.profile) { document.l10n.setAttributes( opt, "migration-wizard-selection-option-with-profile", { sourceBrowser: migrator.displayName, profileName: migrator.profile.name, } ); } else { document.l10n.setAttributes( opt, "migration-wizard-selection-option-without-profile", { sourceBrowser: migrator.displayName, } ); } this.#browserProfileSelectorList.appendChild(opt); } if (state.migrators.length) { this.#onBrowserProfileSelectionChanged( this.#browserProfileSelectorList.firstElementChild ); } } /** * @typedef {object} ProgressState * The migration progress state for a resource. * @property {boolean} inProgress * True if progress is still underway. * @property {string} [message=undefined] * An optional message to display underneath the resource in * the progress dialog. This message is only shown when inProgress * is `false`. */ /** * Called when showing the progress / success page of the wizard. * * @param {object} state * The state object passed into setState. The following properties are * used: * @param {Object} state.progress * An object whose keys match one of DISPLAYED_RESOURCE_TYPES. * * Any resource type not included in state.progress will be hidden. */ #onShowingProgress(state) { // Any resource progress group not included in state.progress is hidden. let resourceGroups = this.#shadowRoot.querySelectorAll( ".resource-progress-group" ); let totalProgressGroups = Object.keys(state.progress).length; let remainingProgressGroups = totalProgressGroups; for (let group of resourceGroups) { let resourceType = group.dataset.resourceType; if (!state.progress.hasOwnProperty(resourceType)) { group.hidden = true; continue; } group.hidden = false; let progressIcon = group.querySelector(".progress-icon"); let successText = group.querySelector(".success-text"); if (state.progress[resourceType].inProgress) { document.l10n.setAttributes( progressIcon, "migration-wizard-progress-icon-in-progress" ); progressIcon.classList.remove("completed"); // With no status text, we re-insert the   so that the status // text area does not fully collapse. successText.appendChild(document.createTextNode("\u00A0")); } else { document.l10n.setAttributes( progressIcon, "migration-wizard-progress-icon-completed" ); progressIcon.classList.add("completed"); successText.textContent = state.progress[resourceType].message; remainingProgressGroups--; } } let migrationDone = remainingProgressGroups == 0; let headerL10nID = migrationDone ? "migration-wizard-progress-done-header" : "migration-wizard-progress-header"; let header = this.#shadowRoot.getElementById("progress-header"); document.l10n.setAttributes(header, headerL10nID); let progressPage = this.#shadowRoot.querySelector( "div[name='page-progress']" ); let doneButton = progressPage.querySelector("#done-button"); let cancelButton = progressPage.querySelector(".cancel-close"); doneButton.hidden = !migrationDone; cancelButton.hidden = migrationDone; } /** * Certain parts of the MigrationWizard need to be modified slightly * in order to work properly with Storybook. This method should be called * to apply those changes after changing state. */ #updateForStorybook() { // The CSS mask used for the progress spinner cannot be loaded via // chrome:// URIs in Storybook. We work around this by exposing the // progress elements as custom parts that the MigrationWizard story // can style on its own. this.#shadowRoot.querySelectorAll(".progress-icon").forEach(progressEl => { if (progressEl.classList.contains("completed")) { progressEl.removeAttribute("part"); } else { progressEl.setAttribute("part", "progress-spinner"); } }); } /** * Takes the current state of the selections page and bundles them * up into a MigrationWizard:BeginMigration event that can be handled * externally to perform the actual migration. */ #doImport() { let panelItem = this.#browserProfileSelector.selectedPanelItem; let key = panelItem.getAttribute("key"); let profile = panelItem.profile; let resourceTypeFields = this.#resourceTypeList.querySelectorAll( "label[data-resource-type]" ); let resourceTypes = []; for (let resourceTypeField of resourceTypeFields) { if (resourceTypeField.control.checked) { resourceTypes.push(resourceTypeField.dataset.resourceType); } } this.dispatchEvent( new CustomEvent("MigrationWizard:BeginMigration", { bubbles: true, detail: { key, profile, resourceTypes, }, }) ); } /** * Changes selected-data-header text and selected-data text based on * how many resources are checked */ async #displaySelectedResources() { let resourceTypeLabels = this.#resourceTypeList.querySelectorAll( "label:not([hidden])[data-resource-type]" ); let totalResources = resourceTypeLabels.length; let checkedResources = 0; let selectedData = this.#shadowRoot.querySelector(".selected-data"); let selectedDataArray = []; const RESOURCE_TYPE_TO_LABEL_IDS = { [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: "migration-list-bookmark-label", [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: "migration-list-password-label", [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: "migration-list-history-label", [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: "migration-list-autofill-label", }; let resourceTypes = Object.keys(RESOURCE_TYPE_TO_LABEL_IDS); let labelIds = Object.values(RESOURCE_TYPE_TO_LABEL_IDS).map(id => { return { id }; }); let labels = await document.l10n.formatValues(labelIds); let resourceTypeLabelMapping = new Map(); for (let i = 0; i < resourceTypes.length; ++i) { let resourceType = resourceTypes[i]; resourceTypeLabelMapping.set(resourceType, labels[i]); } let formatter = new Intl.ListFormat(undefined, { style: "long", type: "conjunction", }); for (let resourceTypeLabel of resourceTypeLabels) { if (resourceTypeLabel.control.checked) { selectedDataArray.push( resourceTypeLabelMapping.get(resourceTypeLabel.dataset.resourceType) ); checkedResources++; } } if (selectedDataArray.length) { selectedDataArray[0] = selectedDataArray[0].charAt(0).toLocaleUpperCase() + selectedDataArray[0].slice(1); selectedData.textContent = formatter.format(selectedDataArray); } else { selectedData.textContent = "\u00A0"; } let selectedDataHeader = this.#shadowRoot.querySelector( ".selected-data-header" ); if (checkedResources == 0) { document.l10n.setAttributes( selectedDataHeader, "migration-no-selected-data-label" ); } else if (checkedResources < totalResources) { document.l10n.setAttributes( selectedDataHeader, "migration-selected-data-label" ); } else { document.l10n.setAttributes( selectedDataHeader, "migration-all-available-data-label" ); } let selectionPage = this.#shadowRoot.querySelector( "div[name='page-selection']" ); selectionPage.toggleAttribute("single-item", totalResources == 1); this.dispatchEvent( new CustomEvent("MigrationWizard:ResourcesUpdated", { bubbles: true }) ); } handleEvent(event) { switch (event.type) { case "click": { if (event.target == this.#importButton) { this.#doImport(); } else if ( event.target.classList.contains("cancel-close") || event.target.id == "done-button" ) { this.dispatchEvent( new CustomEvent("MigrationWizard:Close", { bubbles: true }) ); } else if (event.target == this.#browserProfileSelector) { this.#browserProfileSelectorList.show(event); } else if ( event.currentTarget == this.#browserProfileSelectorList && event.target != this.#browserProfileSelectorList ) { this.#onBrowserProfileSelectionChanged(event.target); } break; } case "change": { if (event.target == this.#browserProfileSelector) { this.#onBrowserProfileSelectionChanged(); } else if (event.target.classList.contains("select-all-checkbox")) { let checkboxes = this.#shadowRoot.querySelectorAll( 'label[data-resource-type] > input[type="checkbox"]' ); for (let checkbox of checkboxes) { checkbox.checked = event.target.checked; } this.#displaySelectedResources(); } else { this.#displaySelectedResources(); } break; } } } } if (globalThis.customElements) { customElements.define("migration-wizard", MigrationWizard); }