chrome-devtools-frontend
Version:
Chrome DevTools UI
281 lines (231 loc) • 9.44 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-imperative-dom-api */
/* eslint-disable rulesdir/no-lit-render-outside-of-view */
import '../../../ui/components/icon_button/icon_button.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as SDK from '../../../core/sdk/sdk.js';
import * as CrUXManager from '../../../models/crux-manager/crux-manager.js';
import * as RenderCoordinator from '../../../ui/components/render_coordinator/render_coordinator.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';
import originMapStyles from './originMap.css.js';
const {html} = Lit;
const UIStrings = {
/**
* @description Title for a column in a data table representing a site origin used for development
*/
developmentOrigin: 'Development origin',
/**
* @description Title for a column in a data table representing a site origin used by real users in a production environment
*/
productionOrigin: 'Production origin',
/**
* @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.',
/**
* @description Warning message explaining that an development origin is already mapped to a productionOrigin.
* @example {https://example.com} PH1
*/
alreadyMapped: '"{PH1}" is already mapped to a production origin.',
/**
* @description Warning message explaining that a page doesn't have enough real user data to show any information for. "Chrome UX Report" is a product name and should not be translated.
*/
pageHasNoData: 'The Chrome UX Report does not have sufficient real user data for this page.',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/OriginMap.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const DEV_ORIGIN_CONTROL = 'developmentOrigin';
const PROD_ORIGIN_CONTROL = 'productionOrigin';
interface ListItem extends CrUXManager.OriginMapping {
isTitleRow?: boolean;
}
export class OriginMap extends UI.Widget.WidgetElement<UI.Widget.Widget> implements UI.ListWidget.Delegate<ListItem> {
#list: UI.ListWidget.ListWidget<ListItem>;
#editor?: UI.ListWidget.Editor<ListItem>;
constructor() {
super();
this.#list = new UI.ListWidget.ListWidget<ListItem>(this, false /* delegatesFocus */, true /* isTable */);
CrUXManager.CrUXManager.instance().getConfigSetting().addChangeListener(this.#updateListFromSetting, this);
this.#updateListFromSetting();
}
override createWidget(): UI.Widget.Widget {
const containerWidget = new UI.Widget.Widget(false, false, this);
this.#list.registerRequiredCSS(originMapStyles);
this.#list.show(containerWidget.contentElement);
return containerWidget;
}
#pullMappingsFromSetting(): ListItem[] {
return CrUXManager.CrUXManager.instance().getConfigSetting().get().originMappings || [];
}
#pushMappingsToSetting(originMappings: ListItem[]): void {
const setting = CrUXManager.CrUXManager.instance().getConfigSetting();
const settingCopy = {...setting.get()};
settingCopy.originMappings = originMappings;
setting.set(settingCopy);
}
#updateListFromSetting(): void {
const mappings = this.#pullMappingsFromSetting();
this.#list.clear();
this.#list.appendItem(
{
developmentOrigin: i18nString(UIStrings.developmentOrigin),
productionOrigin: i18nString(UIStrings.productionOrigin),
isTitleRow: true,
},
false);
for (const originMapping of mappings) {
this.#list.appendItem(originMapping, true);
}
}
#getOrigin(url: string): string|null {
try {
return new URL(url).origin;
} catch {
return null;
}
}
#renderOriginWarning(url: string): Promise<Lit.LitTemplate> {
return RenderCoordinator.write(async () => {
if (!CrUXManager.CrUXManager.instance().isEnabled()) {
return Lit.nothing;
}
const cruxManager = CrUXManager.CrUXManager.instance();
const result = await cruxManager.getFieldDataForPage(url);
const hasFieldData = Object.entries(result).some(([key, value]) => {
if (key === 'warnings') {
return false;
}
return Boolean(value);
});
if (hasFieldData) {
return Lit.nothing;
}
return html`
<devtools-icon
class="origin-warning-icon"
name="warning-filled"
title=${i18nString(UIStrings.pageHasNoData)}
></devtools-icon>
`;
});
}
startCreation(): void {
const targetManager = SDK.TargetManager.TargetManager.instance();
const inspectedURL = targetManager.inspectedURL();
const currentOrigin = this.#getOrigin(inspectedURL) || '';
this.#list.addNewItem(-1, {
developmentOrigin: currentOrigin,
productionOrigin: '',
});
}
renderItem(originMapping: ListItem): Element {
const element = document.createElement('div');
element.classList.add('origin-mapping-row');
element.role = 'row';
let cellRole: 'columnheader'|'cell';
let warningIcon;
if (originMapping.isTitleRow) {
element.classList.add('header');
cellRole = 'columnheader';
warningIcon = Lit.nothing;
} else {
cellRole = 'cell';
warningIcon = Lit.Directives.until(this.#renderOriginWarning(originMapping.productionOrigin));
}
// clang-format off
Lit.render(html`
<div class="origin-mapping-cell development-origin" role=${cellRole}>
<div class="origin" title=${originMapping.developmentOrigin}>${originMapping.developmentOrigin}</div>
</div>
<div class="origin-mapping-cell production-origin" role=${cellRole}>
${warningIcon}
<div class="origin" title=${originMapping.productionOrigin}>${originMapping.productionOrigin}</div>
</div>
`, element, {host: this});
// clang-format on
return element;
}
removeItemRequested(_item: ListItem, index: number): void {
const mappings = this.#pullMappingsFromSetting();
// `index` will be 1-indexed due to the header row
mappings.splice(index - 1, 1);
this.#pushMappingsToSetting(mappings);
}
commitEdit(originMapping: ListItem, editor: UI.ListWidget.Editor<ListItem>, isNew: boolean): void {
originMapping.developmentOrigin = this.#getOrigin(editor.control(DEV_ORIGIN_CONTROL).value) || '';
originMapping.productionOrigin = this.#getOrigin(editor.control(PROD_ORIGIN_CONTROL).value) || '';
const mappings = this.#pullMappingsFromSetting();
if (isNew) {
mappings.push(originMapping);
}
this.#pushMappingsToSetting(mappings);
}
beginEdit(originMapping: ListItem): UI.ListWidget.Editor<ListItem> {
const editor = this.#createEditor();
editor.control(DEV_ORIGIN_CONTROL).value = originMapping.developmentOrigin;
editor.control(PROD_ORIGIN_CONTROL).value = originMapping.productionOrigin;
return editor;
}
#developmentValidator(_item: ListItem, index: number, input: UI.ListWidget.EditorControl):
UI.ListWidget.ValidatorResult {
const origin = this.#getOrigin(input.value);
if (!origin) {
return {valid: false, errorMessage: i18nString(UIStrings.invalidOrigin, {PH1: input.value})};
}
const mappings = this.#pullMappingsFromSetting();
for (let i = 0; i < mappings.length; ++i) {
// `index` will be 1-indexed due to the header row
if (i === index - 1) {
continue;
}
const mapping = mappings[i];
if (mapping.developmentOrigin === origin) {
return {valid: true, errorMessage: i18nString(UIStrings.alreadyMapped, {PH1: origin})};
}
}
return {valid: true};
}
#productionValidator(_item: ListItem, _index: number, input: UI.ListWidget.EditorControl):
UI.ListWidget.ValidatorResult {
const origin = this.#getOrigin(input.value);
if (!origin) {
return {valid: false, errorMessage: i18nString(UIStrings.invalidOrigin, {PH1: input.value})};
}
return {valid: true};
}
#createEditor(): UI.ListWidget.Editor<ListItem> {
if (this.#editor) {
return this.#editor;
}
const editor = new UI.ListWidget.Editor<ListItem>();
this.#editor = editor;
const content = editor.contentElement().createChild('div', 'origin-mapping-editor');
const devInput = editor.createInput(
DEV_ORIGIN_CONTROL, 'text', i18nString(UIStrings.developmentOrigin), this.#developmentValidator.bind(this));
const prodInput = editor.createInput(
PROD_ORIGIN_CONTROL, 'text', i18nString(UIStrings.productionOrigin), this.#productionValidator.bind(this));
// clang-format off
Lit.render(html`
<label class="development-origin-input">
${i18nString(UIStrings.developmentOrigin)}
${devInput}
</label>
<label class="production-origin-input">
${i18nString(UIStrings.productionOrigin)}
${prodInput}
</label>
`, content, {host: this});
// clang-format on
return editor;
}
}
customElements.define('devtools-origin-map', OriginMap);
declare global {
interface HTMLElementTagNameMap {
'devtools-origin-map': OriginMap;
}
}