chrome-devtools-frontend
Version:
Chrome DevTools UI
408 lines (368 loc) • 14.7 kB
text/typescript
// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable rulesdir/no-lit-render-outside-of-view, rulesdir/inject-checkbox-styles */
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 UI from '../../../ui/legacy/legacy.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<boolean> {
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<void> {
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`
<devtools-button
class="config-button"
=${this.#showDialog}
.data=${{
variant: Buttons.Button.Variant.OUTLINED,
title: i18nString(UIStrings.configure),
} as Buttons.Button.ButtonData}
jslog=${VisualLogging.action('timeline.field-data.configure').track({click: true})}
>${i18nString(UIStrings.configure)}</devtools-button>
`;
// clang-format on
}
// clang-format off
return html`
<devtools-button
class="setup-button"
=${this.#showDialog}
.data=${{
variant: Buttons.Button.Variant.PRIMARY,
title: i18nString(UIStrings.setUp),
} as Buttons.Button.ButtonData}
jslog=${VisualLogging.action('timeline.field-data.setup').track({click: true})}
data-field-data-setup
>${i18nString(UIStrings.setUp)}</devtools-button>
`;
// clang-format on
}
#renderEnableButton(): Lit.LitTemplate {
// clang-format off
return html`
<devtools-button
=${() => {
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)}</devtools-button>
`;
// clang-format on
}
#renderDisableButton(): Lit.LitTemplate {
const label = this.#configSetting.get().enabled ? i18nString(UIStrings.optOut) : i18nString(UIStrings.cancel);
// clang-format off
return html`
<devtools-button
=${() => {
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}</devtools-button>
`;
// 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`
<div class="origin-mapping-description">${i18nString(UIStrings.mapDevelopmentOrigins)}</div>
<devtools-origin-map
on-render=${ComponentHelpers.Directives.nodeRenderedCallback(node => {
this.#originMap = node as OriginMap;
})}
></devtools-origin-map>
<div class="origin-mapping-button-section">
<devtools-button
=${() => 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)}</devtools-button>
</div>
`;
// clang-format on
}
#render = (): void => {
const linkEl =
UI.XLink.XLink.create('https://developer.chrome.com/docs/crux', i18n.i18n.lockedString('Chrome UX Report'));
const descriptionEl = i18n.i18n.getFormatLocalizedString(str_, UIStrings.fetchAggregated, {PH1: linkEl});
// clang-format off
const output = html`
<style>${fieldSettingsDialogStyles}</style>
<style>${Input.textInputStyles}</style>
<style>${Input.checkboxStyles}</style>
<div class="open-button-section">${this.#renderOpenButton()}</div>
<devtools-dialog
=${this.#closeDialog}
.position=${Dialogs.Dialog.DialogVerticalPosition.AUTO}
.horizontalAlignment=${Dialogs.Dialog.DialogHorizontalAlignment.CENTER}
.jslogContext=${'timeline.field-data.settings'}
.dialogTitle=${i18nString(UIStrings.configureFieldData)}
on-render=${ComponentHelpers.Directives.nodeRenderedCallback(node => {
this.#dialog = node as Dialogs.Dialog.Dialog;
})}
>
<div class="content">
<div>${descriptionEl}</div>
<div class="privacy-disclosure">
<h3 class="section-title">${i18nString(UIStrings.privacyDisclosure)}</h3>
<div>${i18nString(UIStrings.whenPerformanceIsShown)}</div>
</div>
<details aria-label=${i18nString(UIStrings.advanced)}>
<summary>${i18nString(UIStrings.advanced)}</summary>
<div class="advanced-section-contents">
${this.#renderOriginMapGrid()}
<hr class="divider">
<label class="url-override">
<input
type="checkbox"
.checked=${this.#urlOverrideEnabled}
=${this.#onUrlOverrideEnabledChange}
aria-label=${i18nString(UIStrings.onlyFetchFieldData)}
jslog=${VisualLogging.toggle().track({click: true}).context('field-url-override-enabled')}
/>
${i18nString(UIStrings.onlyFetchFieldData)}
</label>
<input
type="text"
=${this.#onUrlOverrideChange}
=${this.#onUrlOverrideChange}
class="devtools-text-input"
.disabled=${!this.#urlOverrideEnabled}
.value=${this.#urlOverride}
placeholder=${ifDefined(this.#urlOverrideEnabled ? i18nString(UIStrings.url) : undefined)}
/>
${
this.#urlOverrideWarning
? html`<div class="warning" role="alert" aria-label=${this.#urlOverrideWarning}>${this.#urlOverrideWarning}</div>`
: nothing
}
</div>
</details>
<div class="buttons-section">
${this.#renderDisableButton()}
${this.#renderEnableButton()}
</div>
</div>
</devtools-dialog>
`;
// 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;
}
}