// Copyright 2024 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable @devtools/no-lit-render-outside-of-view */ import '../../../ui/kit/kit.js'; import './OriginMap.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as CrUXManager from '../../../models/crux-manager/crux-manager.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as Dialogs from '../../../ui/components/dialogs/dialogs.js'; import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js'; import * as Input from '../../../ui/components/input/input.js'; import * as uiI18n from '../../../ui/i18n/i18n.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import fieldSettingsDialogStyles from './fieldSettingsDialog.css.js'; import type {OriginMap} from './OriginMap.js'; const UIStrings = { /** * @description Text label for a button that opens a dialog to set up field metrics. */ setUp: 'Set up', /** * @description Text label for a button that opens a dialog to configure field metrics. */ configure: 'Configure', /** * @description Text label for a button that enables the collection of field metrics. */ ok: 'Ok', /** * @description Text label for a button that opts out of the collection of field metrics. */ optOut: 'Opt out', /** * @description Text label for a button that cancels the setup of field metrics collection. */ cancel: 'Cancel', /** * @description Text label for a checkbox that controls if a manual URL override is enabled for field metrics. */ onlyFetchFieldData: 'Always show field metrics for the below URL', /** * @description Text label for a text box that that contains the manual override URL for fetching field metrics. */ url: 'URL', /** * @description Warning message explaining that the Chrome UX Report could not find enough real world speed data for the page. "Chrome UX Report" is a product name and should not be translated. */ doesNotHaveSufficientData: 'The Chrome UX Report does not have sufficient real-world speed data for this page.', /** * @description Title for a dialog that contains information and settings related to fetching field metrics. */ configureFieldData: 'Configure field metrics fetching', /** * @description Paragraph explaining where field metrics comes from and and how it can be used. PH1 will be a link with text "Chrome UX Report" that is untranslated because it is a product name. * @example {Chrome UX Report} PH1 */ fetchAggregated: 'Fetch aggregated field metrics from the {PH1} to help you contextualize local measurements with what real users experience on the site.', /** * @description Heading for a section that explains what user data needs to be collected to fetch field metrics. */ privacyDisclosure: 'Privacy disclosure', /** * @description Paragraph explaining what data needs to be sent to Google to fetch field metrics, and when that data will be sent. */ whenPerformanceIsShown: 'When DevTools is open, the URLs you visit will be sent to Google to query field metrics. These requests are not tied to your Google account.', /** * @description Header for a section containing advanced settings */ advanced: 'Advanced', /** * @description Paragraph explaining that the user can associate a development origin with a production origin for the purposes of fetching real user data. */ mapDevelopmentOrigins: 'Set a development origin to automatically get relevant field metrics for its production origin.', /** * @description Text label for a button that adds a new editable row to a data table */ new: 'New', /** * @description Warning message explaining that an input origin is not a valid origin or URL. * @example {http//malformed.com} PH1 */ invalidOrigin: '"{PH1}" is not a valid origin or URL.', } as const; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/FieldSettingsDialog.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); const {html, nothing, Directives: {ifDefined}} = Lit; export class ShowDialog extends Event { static readonly eventName = 'showdialog'; constructor() { super(ShowDialog.eventName); } } export class FieldSettingsDialog extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #dialog?: Dialogs.Dialog.Dialog; #configSetting = CrUXManager.CrUXManager.instance().getConfigSetting(); #urlOverride = ''; #urlOverrideEnabled = false; #urlOverrideWarning = ''; #originMap?: OriginMap; constructor() { super(); const cruxManager = CrUXManager.CrUXManager.instance(); this.#configSetting = cruxManager.getConfigSetting(); this.#resetToSettingState(); this.#render(); } #resetToSettingState(): void { const configSetting = this.#configSetting.get(); this.#urlOverride = configSetting.override || ''; this.#urlOverrideEnabled = configSetting.overrideEnabled || false; this.#urlOverrideWarning = ''; } #flushToSetting(enabled: boolean): void { const value = this.#configSetting.get(); this.#configSetting.set({ ...value, enabled, override: this.#urlOverride, overrideEnabled: this.#urlOverrideEnabled, }); } #onSettingsChanged(): void { void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } async #urlHasFieldData(url: string): Promise { const cruxManager = CrUXManager.CrUXManager.instance(); const result = await cruxManager.getFieldDataForPage(url); return Object.entries(result).some(([key, value]) => { if (key === 'warnings') { return false; } return Boolean(value); }); } async #submit(enabled: boolean): Promise { if (enabled && this.#urlOverrideEnabled) { const origin = this.#getOrigin(this.#urlOverride); if (!origin) { this.#urlOverrideWarning = i18nString(UIStrings.invalidOrigin, {PH1: this.#urlOverride}); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); return; } const hasFieldData = await this.#urlHasFieldData(this.#urlOverride); if (!hasFieldData) { this.#urlOverrideWarning = i18nString(UIStrings.doesNotHaveSufficientData); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); return; } } this.#flushToSetting(enabled); this.#closeDialog(); } #showDialog(): void { if (!this.#dialog) { throw new Error('Dialog not found'); } this.#resetToSettingState(); void this.#dialog.setDialogVisible(true); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); this.dispatchEvent(new ShowDialog()); } #closeDialog(evt?: Dialogs.Dialog.ClickOutsideDialogEvent): void { if (!this.#dialog) { throw new Error('Dialog not found'); } void this.#dialog.setDialogVisible(false); if (evt) { evt.stopImmediatePropagation(); } void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } connectedCallback(): void { this.#configSetting.addChangeListener(this.#onSettingsChanged, this); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } disconnectedCallback(): void { this.#configSetting.removeChangeListener(this.#onSettingsChanged, this); } #renderOpenButton(): Lit.LitTemplate { if (this.#configSetting.get().enabled) { // clang-format off return html` ${i18nString(UIStrings.configure)} `; // clang-format on } // clang-format off return html` ${i18nString(UIStrings.setUp)} `; // clang-format on } #renderEnableButton(): Lit.LitTemplate { // clang-format off return html` { void this.#submit(true); }} .data=${{ variant: Buttons.Button.Variant.PRIMARY, title: i18nString(UIStrings.ok), } as Buttons.Button.ButtonData} class="enable" jslog=${VisualLogging.action('timeline.field-data.enable').track({click: true})} data-field-data-enable >${i18nString(UIStrings.ok)} `; // clang-format on } #renderDisableButton(): Lit.LitTemplate { const label = this.#configSetting.get().enabled ? i18nString(UIStrings.optOut) : i18nString(UIStrings.cancel); // clang-format off return html` { void this.#submit(false); }} .data=${{ variant: Buttons.Button.Variant.OUTLINED, title: label, } as Buttons.Button.ButtonData} jslog=${VisualLogging.action('timeline.field-data.disable').track({click: true})} data-field-data-disable >${label} `; // clang-format on } #onUrlOverrideChange(event: Event): void { event.stopPropagation(); const input = event.target as HTMLInputElement; this.#urlOverride = input.value; this.#urlOverrideWarning = ''; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } #onUrlOverrideEnabledChange(event: Event): void { event.stopPropagation(); const input = event.target as HTMLInputElement; this.#urlOverrideEnabled = input.checked; this.#urlOverrideWarning = ''; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } #getOrigin(url: string): string|null { try { return new URL(url).origin; } catch { return null; } } #renderOriginMapGrid(): Lit.LitTemplate { // clang-format off return html`
${i18nString(UIStrings.mapDevelopmentOrigins)}
{ if (el instanceof HTMLElement) { this.#originMap = el as OriginMap; } })} >
this.#originMap?.startCreation()} .data=${{ variant: Buttons.Button.Variant.TEXT, title: i18nString(UIStrings.new), iconName: 'plus', } as Buttons.Button.ButtonData} jslogContext="new-origin-mapping" >${i18nString(UIStrings.new)}
`; // clang-format on } #render = (): void => { // clang-format off const output = html`
${this.#renderOpenButton()}
{ if (el instanceof HTMLElement) { this.#dialog = el as Dialogs.Dialog.Dialog; } })} >
${uiI18n.getFormatLocalizedStringTemplate( str_, UIStrings.fetchAggregated, { PH1: html`${i18n.i18n.lockedString('Chrome UX Report')}`, }, )}

${i18nString(UIStrings.privacyDisclosure)}

${i18nString(UIStrings.whenPerformanceIsShown)}
${i18nString(UIStrings.advanced)}
${this.#renderOriginMapGrid()}
${ this.#urlOverrideWarning ? html`` : nothing }
${this.#renderDisableButton()} ${this.#renderEnableButton()}
`; // clang-format on Lit.render(output, this.#shadow, {host: this}); }; } customElements.define('devtools-field-settings-dialog', FieldSettingsDialog); declare global { interface HTMLElementTagNameMap { 'devtools-field-settings-dialog': FieldSettingsDialog; } }