diff --git a/front_end/models/crux-manager/CrUXManager.test.ts b/front_end/models/crux-manager/CrUXManager.test.ts index a9047efc005..fbb5c073d7a 100644 --- a/front_end/models/crux-manager/CrUXManager.test.ts +++ b/front_end/models/crux-manager/CrUXManager.test.ts @@ -314,6 +314,40 @@ describeWithMockConnection('CrUXManager', () => { assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/override'); }); + it('should use origin map if set', async () => { + target.setInspectedURL('http://localhost:8080/inspected?param' as Platform.DevToolsPath.UrlString); + cruxManager.getConfigSetting().set({ + enabled: false, + override: '', + originMappings: [{ + developmentOrigin: 'http://localhost:8080', + productionOrigin: 'https://example.com', + }], + }); + + await cruxManager.getFieldDataForCurrentPage(); + + assert.strictEqual(getFieldDataMock.callCount, 1); + assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://example.com/inspected'); + }); + + it('should not use origin map if URL override is set', async () => { + target.setInspectedURL('http://localhost:8080/inspected?param' as Platform.DevToolsPath.UrlString); + cruxManager.getConfigSetting().set({ + enabled: false, + override: 'https://google.com', + originMappings: [{ + developmentOrigin: 'http://localhost:8080', + productionOrigin: 'https://example.com', + }], + }); + + await cruxManager.getFieldDataForCurrentPage(); + + assert.strictEqual(getFieldDataMock.callCount, 1); + assert.strictEqual(getFieldDataMock.firstCall.args[0], 'https://google.com'); + }); + it('should use inspected URL if main document is unavailable', async () => { target.setInspectedURL('https://example.com/inspected' as Platform.DevToolsPath.UrlString); diff --git a/front_end/models/crux-manager/CrUXManager.ts b/front_end/models/crux-manager/CrUXManager.ts index 5866b72a349..a6d0eaaea26 100644 --- a/front_end/models/crux-manager/CrUXManager.ts +++ b/front_end/models/crux-manager/CrUXManager.ts @@ -69,9 +69,15 @@ export type PageResult = { [K in`${PageScope}-${DeviceScope}`]: CrUXResponse|null; }; +export interface OriginMapping { + developmentOrigin: string; + productionOrigin: string; +} + export interface ConfigSetting { enabled: boolean; override: string; + originMappings?: OriginMapping[]; } let cruxManagerInstance: CrUXManager; @@ -115,7 +121,7 @@ export class CrUXManager extends Common.ObjectWrapper.ObjectWrapper useSessionStorage ? Common.Settings.SettingStorageType.Session : Common.Settings.SettingStorageType.Global; this.#configSetting = Common.Settings.Settings.instance().createSetting( - 'field-data', {enabled: false, override: ''}, storageTypeForConsent); + 'field-data', {enabled: false, override: '', originMappings: []}, storageTypeForConsent); this.#configSetting.addChangeListener(() => { void this.#automaticRefresh(); @@ -172,6 +178,24 @@ export class CrUXManager extends Common.ObjectWrapper.ObjectWrapper } } + #getMappedUrl(unmappedUrl: string): string { + try { + const unmapped = new URL(unmappedUrl); + const mappings = this.#configSetting.get().originMappings || []; + const mapping = mappings.find(m => m.developmentOrigin === unmapped.origin); + if (!mapping) { + return unmappedUrl; + } + + const mapped = new URL(mapping.productionOrigin); + mapped.pathname = unmapped.pathname; + + return mapped.href; + } catch { + return unmappedUrl; + } + } + /** * In general, this function should use the main document URL * (i.e. the URL after all redirects but before SPA navigations) @@ -182,7 +206,8 @@ export class CrUXManager extends Common.ObjectWrapper.ObjectWrapper * the main document URL cannot be found. */ async getFieldDataForCurrentPage(): Promise { - const pageUrl = this.#configSetting.get().override || this.#mainDocumentUrl || await this.#getInspectedURL(); + const pageUrl = this.#configSetting.get().override || + this.#getMappedUrl(this.#mainDocumentUrl || await this.#getInspectedURL()); return this.getFieldDataForPage(pageUrl); } diff --git a/front_end/panels/timeline/components/BUILD.gn b/front_end/panels/timeline/components/BUILD.gn index 666c2207cf2..d2ef9ded43f 100644 --- a/front_end/panels/timeline/components/BUILD.gn +++ b/front_end/panels/timeline/components/BUILD.gn @@ -48,6 +48,7 @@ devtools_module("components") { "../../../models/live-metrics:bundle", "../../../models/trace:bundle", "../../../services/trace_bounds:bundle", + "../../../ui/components/data_grid:bundle", "../../../ui/components/dialogs:bundle", "../../../ui/components/helpers:bundle", "../../../ui/components/icon_button:bundle", diff --git a/front_end/panels/timeline/components/FieldSettingsDialog.test.ts b/front_end/panels/timeline/components/FieldSettingsDialog.test.ts index 38fc67f929e..bbc45aa9dfa 100644 --- a/front_end/panels/timeline/components/FieldSettingsDialog.test.ts +++ b/front_end/panels/timeline/components/FieldSettingsDialog.test.ts @@ -17,6 +17,37 @@ const DISABLE_BUTTON_SELECTOR = 'devtools-button[jslogcontext="field-data-disabl const OVERRIDE_CHECKBOX_SELECTOR = 'input[type="checkbox"]'; const OVERRIDE_TEXT_SELECTOR = 'input[type="text"]'; +function getMappingInputs(view: Element): Array { + const dgController = view.shadowRoot!.querySelector('devtools-data-grid-controller'); + const dataGrid = dgController!.shadowRoot!.querySelector('devtools-data-grid'); + const inputs = dataGrid!.shadowRoot!.querySelectorAll('input'); + return Array.from(inputs); +} + +function getAddMappingButton(view: Element): HTMLElementTagNameMap['devtools-button']|null { + const dgController = view.shadowRoot!.querySelector('devtools-data-grid-controller'); + const dataGrid = dgController!.shadowRoot!.querySelector('devtools-data-grid'); + return dataGrid!.shadowRoot!.querySelector('devtools-button#add-mapping-button'); +} + +function getDeleteMappingButtons(view: Element): HTMLElementTagNameMap['devtools-button'][] { + const dgController = view.shadowRoot!.querySelector('devtools-data-grid-controller'); + const dataGrid = dgController!.shadowRoot!.querySelector('devtools-data-grid'); + return Array.from(dataGrid!.shadowRoot!.querySelectorAll('devtools-button.delete-mapping')); +} + +function getNewMappingButton(view: Element): HTMLElementTagNameMap['devtools-button'] { + return view.shadowRoot!.querySelector('.origin-mapping-button-section devtools-button') as + HTMLElementTagNameMap['devtools-button']; +} + +function getMappingTableTextCells(view: Element): HTMLElement[] { + const dgController = view.shadowRoot!.querySelector('devtools-data-grid-controller'); + const dataGrid = dgController!.shadowRoot!.querySelector('devtools-data-grid'); + const cells = Array.from(dataGrid!.shadowRoot!.querySelectorAll('tr[aria-rowindex] td')); + return cells.filter(c => !c.firstElementChild) as HTMLElement[]; +} + function mockResponse(): CrUXManager.CrUXResponse { return { record: { @@ -209,4 +240,266 @@ describeWithMockConnection('FieldSettingsDialog', () => { assert.isFalse(cruxManager.getConfigSetting().get().enabled); assert.strictEqual(cruxManager.getConfigSetting().get().override, ''); }); + + it('should show message for malformed URL', async () => { + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + view.shadowRoot!.querySelector(OVERRIDE_CHECKBOX_SELECTOR)!.click(); + + await coordinator.done(); + + const urlOverride = view.shadowRoot!.querySelector(OVERRIDE_TEXT_SELECTOR) as HTMLInputElement; + urlOverride.value = '//example.com'; + urlOverride.dispatchEvent(new Event('change')); + + await coordinator.done(); + + view.shadowRoot!.querySelector(ENABLE_BUTTON_SELECTOR)!.click(); + + await coordinator.done({waitForWork: true}); + + assert.strictEqual( + view.shadowRoot!.querySelector('.warning')!.textContent, '"//example.com" is not a valid origin or URL.'); + + assert.isTrue(view.shadowRoot!.querySelector('devtools-dialog')!.shadowRoot!.querySelector('dialog')!.open); + assert.isFalse(cruxManager.getConfigSetting().get().enabled); + assert.strictEqual(cruxManager.getConfigSetting().get().override, ''); + }); + + describe('origin mapping', () => { + it('should flush to settings on submit', async () => { + mockFieldData['url-ALL'] = mockResponse(); + + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + const newMappingButton = getNewMappingButton(view); + newMappingButton.click(); + + await coordinator.done(); + + const [devInput, prodInput] = getMappingInputs(view); + devInput!.value = 'http://localhost:8080/page1/'; + devInput!.dispatchEvent(new Event('change')); + + prodInput!.value = 'https://example.com/#asdf'; + prodInput!.dispatchEvent(new Event('change')); + + await coordinator.done(); + + const addMappingButton = getAddMappingButton(view); + addMappingButton!.click(); + + await coordinator.done({waitForWork: true}); + + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells.map(c => c!.textContent), [ + 'http://localhost:8080', + 'https://example.com', + ]); + + view.shadowRoot!.querySelector(ENABLE_BUTTON_SELECTOR)!.click(); + + await coordinator.done({waitForWork: true}); + + assert.isFalse(view.shadowRoot!.querySelector('devtools-dialog')!.shadowRoot!.querySelector('dialog')!.open); + assert.isTrue(cruxManager.getConfigSetting().get().enabled); + assert.strictEqual(cruxManager.getConfigSetting().get().override, ''); + assert.deepStrictEqual(cruxManager.getConfigSetting().get().originMappings, [ + {developmentOrigin: 'http://localhost:8080', productionOrigin: 'https://example.com'}, + ]); + }); + + it('should warn if either URL is invalid', async () => { + mockFieldData['url-ALL'] = mockResponse(); + + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + const newMappingButton = getNewMappingButton(view); + newMappingButton.click(); + + await coordinator.done(); + + const [devInput, prodInput] = getMappingInputs(view); + devInput!.value = 'bad-one'; + devInput!.dispatchEvent(new Event('change')); + + prodInput!.value = 'bad-two'; + prodInput!.dispatchEvent(new Event('change')); + + await coordinator.done(); + + const addMappingButton = getAddMappingButton(view); + addMappingButton!.click(); + + await coordinator.done({waitForWork: true}); + + { + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells, []); + } + + assert.strictEqual( + view.shadowRoot!.querySelector('.warning')!.textContent, '"bad-one" is not a valid origin or URL.'); + + devInput!.value = 'https://localhost:8080'; + devInput!.dispatchEvent(new Event('change')); + + await coordinator.done(); + + addMappingButton!.click(); + + await coordinator.done({waitForWork: true}); + + { + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells, []); + } + + assert.strictEqual( + view.shadowRoot!.querySelector('.warning')!.textContent, '"bad-two" is not a valid origin or URL.'); + }); + + it('should warn if there is not CrUX data for the prod origin', async () => { + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + const newMappingButton = getNewMappingButton(view); + newMappingButton.click(); + + await coordinator.done(); + + const [devInput, prodInput] = getMappingInputs(view); + devInput!.value = 'http://localhost:8080'; + devInput!.dispatchEvent(new Event('change')); + + prodInput!.value = 'https://no-field.com'; + prodInput!.dispatchEvent(new Event('change')); + + await coordinator.done(); + + const addMappingButton = getAddMappingButton(view); + addMappingButton!.click(); + + await coordinator.done({waitForWork: true}); + + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells, []); + + assert.strictEqual( + view.shadowRoot!.querySelector('.warning')!.textContent, + 'The Chrome UX Report does not have sufficient real-world speed data for this page.'); + }); + + it('should warn if a mapping for the dev origin already exists', async () => { + mockFieldData['url-ALL'] = mockResponse(); + cruxManager.getConfigSetting().set({ + enabled: false, + override: '', + originMappings: [{developmentOrigin: 'http://localhost:8080', productionOrigin: 'https://google.com'}], + }); + + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + const newMappingButton = getNewMappingButton(view); + newMappingButton.click(); + + await coordinator.done(); + + const [devInput, prodInput] = getMappingInputs(view); + devInput!.value = 'http://localhost:8080'; + devInput!.dispatchEvent(new Event('change')); + + prodInput!.value = 'https://example.com'; + prodInput!.dispatchEvent(new Event('change')); + + await coordinator.done(); + + const addMappingButton = getAddMappingButton(view); + addMappingButton!.click(); + + await coordinator.done({waitForWork: true}); + + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells.map(c => c!.textContent), [ + 'http://localhost:8080', + 'https://google.com', + ]); + + assert.strictEqual( + view.shadowRoot!.querySelector('.warning')!.textContent, + '"http://localhost:8080" is already mapped to a production origin.'); + }); + + it('should handle deleting entries', async () => { + cruxManager.getConfigSetting().set({ + enabled: false, + override: '', + originMappings: [{developmentOrigin: 'http://localhost:8080', productionOrigin: 'https://google.com'}], + }); + + const view = new Components.FieldSettingsDialog.FieldSettingsDialog(); + renderElementIntoDOM(view); + await coordinator.done(); + + view.shadowRoot!.querySelector(OPEN_BUTTON_SELECTOR)!.click(); + + await coordinator.done(); + + { + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells.map(c => c!.textContent), [ + 'http://localhost:8080', + 'https://google.com', + ]); + } + + const deleteButtons = getDeleteMappingButtons(view); + deleteButtons[0].click(); + + await coordinator.done(); + + { + const textCells = getMappingTableTextCells(view); + assert.deepStrictEqual(textCells, []); + } + + view.shadowRoot!.querySelector(ENABLE_BUTTON_SELECTOR)!.click(); + + await coordinator.done({waitForWork: true}); + + assert.isFalse(view.shadowRoot!.querySelector('devtools-dialog')!.shadowRoot!.querySelector('dialog')!.open); + assert.isTrue(cruxManager.getConfigSetting().get().enabled); + assert.strictEqual(cruxManager.getConfigSetting().get().override, ''); + assert.deepStrictEqual(cruxManager.getConfigSetting().get().originMappings, []); + }); + }); }); diff --git a/front_end/panels/timeline/components/FieldSettingsDialog.ts b/front_end/panels/timeline/components/FieldSettingsDialog.ts index df313176d90..ae0c7e20176 100644 --- a/front_end/panels/timeline/components/FieldSettingsDialog.ts +++ b/front_end/panels/timeline/components/FieldSettingsDialog.ts @@ -5,6 +5,7 @@ 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 DataGrid from '../../../ui/components/data_grid/data_grid.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'; @@ -70,6 +71,50 @@ const UIStrings = { * @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: 'Map development origins to production origins.', + /** + * @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 Label for an input that accepts a site origin used for development + * @example {http://localhost:8080} PH1 + */ + developmentOriginValue: 'Development origin: {PH1}', + /** + * @description Label for an input that accepts a site origin used by real users in a production environment + * @example {https://example.com} PH1 + */ + productionOriginValue: 'Production origin: {PH1}', + /** + * @description Text label for a button that adds a new editable row to a data table + */ + new: 'New', + /** + * @description Text label for a button that saves the changes of an editable row in a data table + */ + add: 'Add', + /** + * @description Text label for a button that deletes a row in a data table + */ + delete: 'Delete', + /** + * @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.', }; const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/FieldSettingsDialog.ts', UIStrings); @@ -95,7 +140,12 @@ export class FieldSettingsDialog extends HTMLElement { #urlOverride: string = ''; #urlOverrideEnabled: boolean = false; - #showInvalidUrlWarning: boolean = false; + #urlOverrideWarning = ''; + #originMapWarning = ''; + #originMappings: CrUXManager.OriginMapping[] = []; + #isEditingOriginGrid = false; + #editGridDevelopmentOrigin = ''; + #editGridProductionOrigin = ''; constructor() { super(); @@ -104,21 +154,28 @@ export class FieldSettingsDialog extends HTMLElement { this.#configSetting = cruxManager.getConfigSetting(); - this.#pullFromSettings(); + this.#resetToSettingState(); this.#render(); } - #pullFromSettings(): void { - this.#urlOverride = this.#configSetting.get().override; + #resetToSettingState(): void { + const configSetting = this.#configSetting.get(); + this.#urlOverride = configSetting.override; this.#urlOverrideEnabled = Boolean(this.#urlOverride); - this.#showInvalidUrlWarning = false; + this.#originMappings = configSetting.originMappings || []; + this.#urlOverrideWarning = ''; + this.#originMapWarning = ''; + this.#isEditingOriginGrid = false; + this.#editGridDevelopmentOrigin = ''; + this.#editGridProductionOrigin = ''; } #flushToSetting(enabled: boolean): void { this.#configSetting.set({ enabled, override: this.#urlOverrideEnabled ? this.#urlOverride : '', + originMappings: this.#originMappings, }); } @@ -126,12 +183,24 @@ export class FieldSettingsDialog extends HTMLElement { 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.values(result).some(v => v); + } + async #submit(enabled: boolean): Promise { if (enabled && this.#urlOverrideEnabled) { - const cruxManager = CrUXManager.CrUXManager.instance(); - const result = await cruxManager.getFieldDataForPage(this.#urlOverride); - if (Object.values(result).every(v => !v)) { - this.#showInvalidUrlWarning = true; + 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; } @@ -144,7 +213,7 @@ export class FieldSettingsDialog extends HTMLElement { if (!this.#dialog) { throw new Error('Dialog not found'); } - this.#pullFromSettings(); + this.#resetToSettingState(); void this.#dialog.setDialogVisible(true); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); this.dispatchEvent(new ShowDialog()); @@ -240,19 +309,250 @@ export class FieldSettingsDialog extends HTMLElement { } #onUrlOverrideChange(event: Event): void { + event.stopPropagation(); const input = event.target as HTMLInputElement; this.#urlOverride = input.value; - this.#showInvalidUrlWarning = false; + 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.#showInvalidUrlWarning = false; + this.#urlOverrideWarning = ''; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + } + + // Cannot use Lit template automatic binding because this event function is technically added to a different component + #onEditGridDevelopmentOriginChange = (event: Event): void => { + event.stopPropagation(); + const input = event.target as HTMLInputElement; + this.#editGridDevelopmentOrigin = input.value; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + }; + + // Cannot use Lit template automatic binding because this event function is technically added to a different component + #onEditGridProductionOriginChange = (event: Event): void => { + event.stopPropagation(); + const input = event.target as HTMLInputElement; + this.#editGridProductionOrigin = input.value; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + }; + + #getOrigin(url: string): string|null { + try { + return new URL(url).origin; + } catch { + return null; + } + } + + #startEditingOriginMapping(): void { + this.#editGridDevelopmentOrigin = ''; + this.#editGridProductionOrigin = ''; + this.#isEditingOriginGrid = true; + this.#originMapWarning = ''; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + } + + async #addOriginMapping(): Promise { + const developmentOrigin = this.#getOrigin(this.#editGridDevelopmentOrigin); + const productionOrigin = this.#getOrigin(this.#editGridProductionOrigin); + + if (!developmentOrigin) { + this.#originMapWarning = i18nString(UIStrings.invalidOrigin, {PH1: this.#editGridDevelopmentOrigin}); + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + return; + } + + if (this.#originMappings.find(m => m.developmentOrigin === developmentOrigin)) { + this.#originMapWarning = i18nString(UIStrings.alreadyMapped, {PH1: developmentOrigin}); + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + return; + } + + if (!productionOrigin) { + this.#originMapWarning = i18nString(UIStrings.invalidOrigin, {PH1: this.#editGridProductionOrigin}); + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + return; + } + + const hasFieldData = await this.#urlHasFieldData(productionOrigin); + if (!hasFieldData) { + this.#originMapWarning = i18nString(UIStrings.doesNotHaveSufficientData, {PH1: this.#editGridProductionOrigin}); + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + return; + } + + this.#originMappings.push({developmentOrigin, productionOrigin}); + this.#editGridDevelopmentOrigin = ''; + this.#editGridProductionOrigin = ''; + this.#isEditingOriginGrid = false; + this.#originMapWarning = ''; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); + } + + #deleteOriginMapping(index: number): void { + this.#originMappings.splice(index, 1); void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#render); } + #renderOriginMapGrid(): LitHtml.LitTemplate { + const rows: DataGrid.DataGridUtils.Row[] = this.#originMappings.map((mapping, index) => { + return { + cells: [ + { + columnId: 'development-origin', + value: mapping.developmentOrigin, + title: mapping.developmentOrigin, + }, + { + columnId: 'production-origin', + value: mapping.productionOrigin, + title: mapping.productionOrigin, + }, + { + columnId: 'action-button', + value: i18nString(UIStrings.delete), + // clang-format off + renderer: value => html` + <${Buttons.Button.Button.litTagName} + class="delete-mapping" + .data=${{ + variant: Buttons.Button.Variant.ICON, + size: Buttons.Button.Size.SMALL, + title: value, + iconName: 'bin', + jslogContext: 'delete-origin-mapping', + } as Buttons.Button.ButtonData} + @click=${() => this.#deleteOriginMapping(index)} + > + `, + // clang-format on + }, + ], + }; + }); + + if (this.#isEditingOriginGrid) { + // Input element is in a different component so we need to inject this in the style attribute + const inputStyle = 'width: 100%; box-sizing: border-box; border: none; background: none;'; + + rows.push({ + cells: [ + { + columnId: 'development-origin', + value: this.#editGridDevelopmentOrigin, + // clang-format off + renderer: value => html` + + `, + // clang-format on + }, + { + columnId: 'production-origin', + value: this.#editGridProductionOrigin, + // clang-format off + renderer: value => html` + + `, + // clang-format on + }, + { + columnId: 'action-button', + value: i18nString(UIStrings.add), + renderer: value => html` + <${Buttons.Button.Button.litTagName} + id="add-mapping-button" + .data=${{ + variant: Buttons.Button.Variant.ICON, + size: Buttons.Button.Size.SMALL, + title: value, + iconName: 'plus', + disabled: !this.#editGridDevelopmentOrigin || !this.#editGridProductionOrigin, + jslogContext: 'add-origin-mapping', + } as Buttons.Button.ButtonData} + @click=${() => this.#addOriginMapping()} + > + `, + }, + ], + }); + } + + const gridData: DataGrid.DataGridController.DataGridControllerData = { + columns: [ + { + id: 'development-origin', + title: i18nString(UIStrings.developmentOrigin), + widthWeighting: 13, + hideable: false, + visible: true, + sortable: false, + }, + { + id: 'production-origin', + title: i18nString(UIStrings.productionOrigin), + widthWeighting: 13, + hideable: false, + visible: true, + sortable: false, + }, + { + id: 'action-button', + title: '', + widthWeighting: 3, + hideable: false, + visible: true, + sortable: false, + }, + ], + rows, + }; + + // clang-format off + return html` +
${i18nString(UIStrings.mapDevelopmentOrigins)}
+ <${DataGrid.DataGridController.DataGridController.litTagName} + class="origin-mapping-grid" + .data=${gridData as DataGrid.DataGridController.DataGridControllerData} + > + ${this.#originMapWarning ? html` + + ` : nothing} +
+ <${Buttons.Button.Button.litTagName} + @click=${this.#startEditingOriginMapping} + .data=${{ + variant: Buttons.Button.Variant.TEXT, + title: i18nString(UIStrings.new), + iconName: 'plus', + disabled: this.#isEditingOriginGrid, + } as Buttons.Button.ButtonData} + jslogContext=${'new-origin-mapping'} + >${i18nString(UIStrings.new)} +
+ `; + // clang-format on + } + #render = (): void => { // "Chrome UX Report" is intentionally left untranslated because it is a product name. const linkEl = UI.XLink.XLink.create('https://developer.chrome.com/docs/crux', 'Chrome UX Report'); @@ -280,27 +580,32 @@ export class FieldSettingsDialog extends HTMLElement {
${i18nString(UIStrings.advanced)} -
${this.#renderDisableButton()} diff --git a/front_end/panels/timeline/components/fieldSettingsDialog.css b/front_end/panels/timeline/components/fieldSettingsDialog.css index d8f2f1cabb5..9c1a7e4d903 100644 --- a/front_end/panels/timeline/components/fieldSettingsDialog.css +++ b/front_end/panels/timeline/components/fieldSettingsDialog.css @@ -55,14 +55,30 @@ details > summary { flex-direction: row; } +.origin-mapping-grid { + border: 1px solid var(--sys-color-divider); + margin-top: 8px; +} + +.origin-mapping-button-section { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 6px; +} + .config-button { margin-left: auto; } +.advanced-section-contents { + margin: 4px 0 14px; +} + .buttons-section { display: flex; justify-content: flex-end; - margin-top: 14px; + margin-top: 6px; gap: 8px; } @@ -94,3 +110,9 @@ x-link { /* stylelint-disable-line selector-type-no-unknown */ color: var(--sys-color-primary); text-decoration-line: underline; } + +.divider { + margin: 10px 0; + border: none; + border-top: 1px solid var(--sys-color-divider); +} diff --git a/front_end/ui/visual_logging/KnownContextValues.ts b/front_end/ui/visual_logging/KnownContextValues.ts index 8e3920ae582..2e4b3fb6784 100644 --- a/front_end/ui/visual_logging/KnownContextValues.ts +++ b/front_end/ui/visual_logging/KnownContextValues.ts @@ -125,6 +125,7 @@ export const knownContextValues = new Set([ 'accessibility.view', 'achromatopsia', 'action', + 'action-button', 'activation-value', 'active', 'active-keybind-set', @@ -154,6 +155,7 @@ export const knownContextValues = new Set([ 'add-logpoint', 'add-new', 'add-operator', + 'add-origin-mapping', 'add-properties', 'add-property-path-to-watch', 'add-script-to-ignorelist', @@ -780,6 +782,7 @@ export const knownContextValues = new Set([ 'delete-event-listener', 'delete-file-confirmation', 'delete-folder-confirmation', + 'delete-origin-mapping', 'delete-recording', 'delete-selected', 'delete-watch-expression', @@ -804,6 +807,7 @@ export const knownContextValues = new Set([ 'deuteranopia', 'dev-tools-default', 'developer-resources', + 'development-origin', 'device', 'device-fold', 'device-frame-enable', @@ -1940,6 +1944,7 @@ export const knownContextValues = new Set([ 'priority', 'privacy-notice', 'private-state-tokens', + 'production-origin', 'profile-loading-failed', 'profile-options', 'profile-view',