[go: nahoru, domu]

blob: fa65303c74de727e58793849916fe47235d43aae [file] [log] [blame]
// Copyright 2023 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.
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import type * as Puppeteer from '../../../third_party/puppeteer/puppeteer.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as LitHtml from '../../../ui/lit-html/lit-html.js';
import * as Controllers from '../controllers/controllers.js';
import * as Models from '../models/models.js';
import * as Util from '../util/util.js';
import {RecorderInput} from './RecorderInput.js';
import stepEditorStyles from './stepEditor.css.js';
import {
ArrayAssignments,
assert,
deepFreeze,
immutableDeepAssign,
InsertAssignment,
type Assignments,
type DeepImmutable,
type DeepPartial,
type Keys,
type OptionalKeys,
type RequiredKeys,
} from './util.js';
const {html, Decorators, Directives, LitElement} = LitHtml;
const {customElement, property, state} = Decorators;
const {live} = Directives;
type StepFor<Type> = Extract<Models.Schema.Step, {type: Type}>;
type Attribute = Keys<Models.Schema.Step>;
type DataType<A extends Attribute> = ReturnType<typeof typeConverters[typeof dataTypeByAttribute[A]]>;
const typeConverters = Object.freeze({
string: (value: string): string => value.trim(),
number: (value: string): number => {
const number = parseFloat(value);
if (Number.isNaN(number)) {
return 0;
}
return number;
},
boolean: (value: string): boolean => {
if (value.toLowerCase() === 'true') {
return true;
}
return false;
},
});
const dataTypeByAttribute = Object.freeze({
selectors: 'string',
offsetX: 'number',
offsetY: 'number',
target: 'string',
frame: 'number',
assertedEvents: 'string',
value: 'string',
key: 'string',
operator: 'string',
count: 'number',
expression: 'string',
x: 'number',
y: 'number',
url: 'string',
type: 'string',
timeout: 'number',
duration: 'number',
button: 'string',
deviceType: 'string',
width: 'number',
height: 'number',
deviceScaleFactor: 'number',
isMobile: 'boolean',
hasTouch: 'boolean',
isLandscape: 'boolean',
download: 'number',
upload: 'number',
latency: 'number',
name: 'string',
parameters: 'string',
visible: 'boolean',
properties: 'string',
attributes: 'string',
} as const);
const defaultValuesByAttribute = deepFreeze({
selectors: [['.cls']],
offsetX: 1,
offsetY: 1,
target: 'main',
frame: [0],
assertedEvents: [
{type: 'navigation', url: 'https://example.com', title: 'Title'},
],
value: 'Value',
key: 'Enter',
operator: '>=',
count: 1,
expression: 'true',
x: 0,
y: 0,
url: 'https://example.com',
timeout: 5000,
duration: 50,
deviceType: 'mouse',
button: 'primary',
type: 'click',
width: 800,
height: 600,
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
isLandscape: true,
download: 1000,
upload: 1000,
latency: 25,
name: 'customParam',
parameters: '{}',
properties: '{}',
attributes: [{name: 'attribute', value: 'value'}],
visible: true,
});
const attributesByType = deepFreeze<{
[Type in Models.Schema.StepType]:
{required: Exclude<RequiredKeys<StepFor<Type>>, 'type'>[], optional: OptionalKeys<StepFor<Type>>[]};
}>({
[Models.Schema.StepType.Click]: {
required: ['selectors', 'offsetX', 'offsetY'],
optional: [
'assertedEvents',
'button',
'deviceType',
'duration',
'frame',
'target',
'timeout',
],
},
[Models.Schema.StepType.DoubleClick]: {
required: ['offsetX', 'offsetY', 'selectors'],
optional: [
'assertedEvents',
'button',
'deviceType',
'frame',
'target',
'timeout',
],
},
[Models.Schema.StepType.Hover]: {
required: ['selectors'],
optional: ['assertedEvents', 'frame', 'target', 'timeout'],
},
[Models.Schema.StepType.Change]: {
required: ['selectors', 'value'],
optional: ['assertedEvents', 'frame', 'target', 'timeout'],
},
[Models.Schema.StepType.KeyDown]: {
required: ['key'],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.KeyUp]: {
required: ['key'],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.Scroll]: {
required: [],
optional: ['assertedEvents', 'frame', 'target', 'timeout', 'x', 'y'],
},
[Models.Schema.StepType.Close]: {
required: [],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.Navigate]: {
required: ['url'],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.WaitForElement]: {
required: ['selectors'],
optional: [
'assertedEvents',
'attributes',
'count',
'frame',
'operator',
'properties',
'target',
'timeout',
'visible',
],
},
[Models.Schema.StepType.WaitForExpression]: {
required: ['expression'],
optional: ['assertedEvents', 'frame', 'target', 'timeout'],
},
[Models.Schema.StepType.CustomStep]: {
required: ['name', 'parameters'],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.EmulateNetworkConditions]: {
required: ['download', 'latency', 'upload'],
optional: ['assertedEvents', 'target', 'timeout'],
},
[Models.Schema.StepType.SetViewport]: {
required: [
'deviceScaleFactor',
'hasTouch',
'height',
'isLandscape',
'isMobile',
'width',
],
optional: ['assertedEvents', 'target', 'timeout'],
},
});
const UIStrings = {
/**
*@description The text that is disabled when the steps were not saved due to an error. The error message itself is always in English and not translated.
*@example {Saving failed} error
*/
notSaved: 'Not saved: {error}',
/**
*@description The button title that adds a new attribute to the form.
*@example {timeout} attributeName
*/
addAttribute: 'Add {attributeName}',
/**
*@description The title of a button that deletes an attribute from the form.
*/
deleteRow: 'Delete row',
/**
*@description The title of a button that allows you to select an element on the page and update CSS/ARIA selectors.
*/
selectorPicker: 'Select an element in the page to update selectors',
/**
*@description The title of a button that adds a new input field for the entry of the frame index. Frame index is the number of the frame within the page's frame tree.
*/
addFrameIndex: 'Add frame index within the frame tree',
/**
*@description The title of a button that removes a frame index field from the form.
*/
removeFrameIndex: 'Remove frame index',
/**
*@description The title of a button that adds a field to input a part of a selector in the editor form.
*/
addSelectorPart: 'Add a selector part',
/**
*@description The title of a button that removes a field to input a part of a selector in the editor form.
*/
removeSelectorPart: 'Remove a selector part',
/**
*@description The title of a button that adds a field to input a selector in the editor form.
*/
addSelector: 'Add a selector',
/**
*@description The title of a button that removes a field to input a selector in the editor form.
*/
removeSelector: 'Remove a selector',
/**
*@description The error message display when a user enters a type in the input not associates with any existing types.
*/
unknownActionType: 'Unknown action type.',
};
const str_ = i18n.i18n.registerUIStrings('panels/recorder/components/StepEditor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const deleteIconUrl = new URL('../images/delete_icon.svg', import.meta.url).toString();
const plusIconUrl = new URL('../images/plus_icon.svg', import.meta.url).toString();
const selectIconUrl = new URL('../images/select_icon.svg', import.meta.url).toString();
const minusIconUrl = new URL('../images/minus_icon.svg', import.meta.url).toString();
declare global {
interface HTMLElementTagNameMap {
'devtools-recorder-step-editor': StepEditor;
'devtools-recorder-selector-picker-button': RecorderSelectorPickerButton;
}
}
export class StepEditedEvent extends Event {
static readonly eventName = 'stepedited';
data: Models.Schema.Step;
constructor(step: Models.Schema.Step) {
super(StepEditedEvent.eventName, {bubbles: true, composed: true});
this.data = step;
}
}
export interface EditorState {
type: Models.Schema.StepType;
target?: string;
selectors?: string[][];
frame?: number[];
x?: number;
y?: number;
offsetX?: number;
offsetY?: number;
key?: string;
expression?: string;
value?: string;
operator?: string;
count?: number;
assertedEvents?: Models.Schema.AssertedEvent[];
url?: string;
timeout?: number;
button?: string;
duration?: number;
deviceType?: string;
width?: number;
height?: number;
deviceScaleFactor?: number;
isMobile?: boolean;
hasTouch?: boolean;
isLandscape?: boolean;
download?: number;
upload?: number;
latency?: number;
name?: string;
parameters?: string;
visible?: boolean;
properties?: string;
attributes?: Array<{name: string, value: string}>;
}
// Makes use of the fact that JSON values get their undefined values cleaned
// after stringification.
const cleanUndefineds = <T>(value: T): T => {
return JSON.parse(JSON.stringify(value));
};
interface Puppeteer {
page: Puppeteer.Page;
browser: Puppeteer.Browser;
}
export class EditorState {
static #puppeteer: Util.SharedObject.SharedObject<Puppeteer> = new Util.SharedObject.SharedObject(
() => Models.RecordingPlayer.RecordingPlayer.connectPuppeteer(),
({browser}) => Models.RecordingPlayer.RecordingPlayer.disconnectPuppeteer(browser));
static async default(type: Models.Schema.StepType): Promise<DeepImmutable<EditorState>> {
const state = {type};
const attributes = attributesByType[state.type];
let promise: Promise<unknown> = Promise.resolve();
for (const attribute of attributes.required) {
promise = Promise.all([
promise,
(async(): Promise<EditorState> => Object.assign(state, {
[attribute]: await this.defaultByAttribute(state, attribute),
}))(),
]);
}
await promise;
return Object.freeze(state);
}
static async defaultByAttribute<Attribute extends keyof typeof defaultValuesByAttribute>(
state: DeepImmutable<EditorState>,
attribute: Attribute): Promise<DeepImmutable<typeof defaultValuesByAttribute[Attribute]>>;
static async defaultByAttribute(_state: DeepImmutable<EditorState>, attribute: keyof typeof defaultValuesByAttribute):
Promise<unknown> {
return this.#puppeteer.run(puppeteer => {
switch (attribute) {
case 'assertedEvents': {
return immutableDeepAssign(defaultValuesByAttribute.assertedEvents, new ArrayAssignments({
0: {
url: puppeteer.page.url() || defaultValuesByAttribute.assertedEvents[0].url,
},
}));
}
case 'url': {
return puppeteer.page.url() || defaultValuesByAttribute.url;
}
case 'height': {
return (
puppeteer.page.evaluate(() => (visualViewport as VisualViewport).height) ||
defaultValuesByAttribute.height);
}
case 'width': {
return (
puppeteer.page.evaluate(() => (visualViewport as VisualViewport).width) ||
defaultValuesByAttribute.width);
}
default: {
return defaultValuesByAttribute[attribute];
}
}
});
}
static fromStep(step: DeepImmutable<Models.Schema.Step>): DeepImmutable<EditorState> {
const state = structuredClone(step) as EditorState;
for (const key of ['parameters', 'properties'] as Array<'properties'>) {
if (key in step && step[key] !== undefined) {
state[key] = JSON.stringify(step[key]);
}
}
if ('attributes' in step && step.attributes) {
state.attributes = [];
for (const [name, value] of Object.entries(step.attributes)) {
state.attributes.push({name, value});
}
}
if ('selectors' in step) {
state.selectors = step.selectors.map(selector => {
if (typeof selector === 'string') {
return [selector];
}
return [...selector];
});
}
return deepFreeze(state as EditorState);
}
static toStep(state: DeepImmutable<EditorState>): Models.Schema.Step {
const step = structuredClone(state) as Models.Schema.Step;
for (const key of ['parameters', 'properties'] as Array<'properties'>) {
const value = state[key];
if (value) {
Object.assign(step, {[key]: JSON.parse(value)});
}
}
if (state.attributes) {
if (state.attributes.length !== 0) {
const attributes = {};
for (const {name, value} of state.attributes) {
Object.assign(attributes, {[name]: value});
}
Object.assign(step, {attributes});
} else if ('attributes' in step) {
delete step.attributes;
}
}
if (state.selectors) {
const selectors = state.selectors.filter(selector => selector.length > 0).map(selector => {
if (selector.length === 1) {
return selector[0];
}
return [...selector];
});
if (selectors.length !== 0) {
Object.assign(step, {selectors});
} else if ('selectors' in step) {
// @ts-expect-error We want to trigger an error in the parsing phase.
delete step.selectors;
}
}
if (state.frame && state.frame.length === 0 && 'frame' in step) {
delete step.frame;
}
return cleanUndefineds(Models.SchemaUtils.parseStep(step));
}
}
/**
* @fires RequestSelectorAttributeEvent#requestselectorattribute
* @fires SelectorPickedEvent#selectorpicked
*/
@customElement('devtools-recorder-selector-picker-button')
class RecorderSelectorPickerButton extends LitElement {
static override styles = [stepEditorStyles];
@property() declare disabled: boolean;
#picker = new Controllers.SelectorPicker.SelectorPicker(this);
constructor() {
super();
this.disabled = false;
}
#handleClickEvent = (event: MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
void this.#picker.toggle();
};
override disconnectedCallback(): void {
super.disconnectedCallback();
void this.#picker.stop();
}
protected override render(): LitHtml.TemplateResult|undefined {
if (this.disabled) {
return;
}
return html`<devtools-button
@click=${this.#handleClickEvent}
.title=${i18nString(UIStrings.selectorPicker)}
class="selector-picker"
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${selectIconUrl}
.active=${this.#picker.active}
.variant=${Buttons.Button.Variant.SECONDARY}
></devtools-button>`;
}
}
/**
* @fires RequestSelectorAttributeEvent#requestselectorattribute
* @fires StepEditedEvent#stepedited
*/
@customElement('devtools-recorder-step-editor')
export class StepEditor extends LitElement {
static override styles = [stepEditorStyles];
@state() private declare state: DeepImmutable<EditorState>;
@state() private declare error: string|undefined;
@property() declare isTypeEditable: boolean;
@property() declare disabled: boolean;
#renderedAttributes: Set<Attribute> = new Set();
constructor() {
super();
this.state = {type: Models.Schema.StepType.WaitForElement};
this.isTypeEditable = true;
this.disabled = false;
}
protected override createRenderRoot(): Element|ShadowRoot {
const root = super.createRenderRoot();
root.addEventListener('keydown', this.#handleKeyDownEvent);
return root;
}
set step(step: DeepImmutable<Models.Schema.Step>) {
this.state = deepFreeze(EditorState.fromStep(step));
this.error = undefined;
}
#commit(updatedState: DeepImmutable<EditorState>): void {
try {
this.dispatchEvent(new StepEditedEvent(EditorState.toStep(updatedState)));
// Note we don't need to update this variable since it will come from up
// the tree, but processing up the tree is asynchronous implying we cannot
// reliably know when the state will come back down. Since we need to
// focus the DOM elements that may be created as a result of this new
// state, we set it here for waiting on the updateComplete promise later.
this.state = updatedState;
} catch (error) {
this.error = error.message;
}
}
#handleSelectorPickedEvent = (event: Controllers.SelectorPicker.SelectorPickedEvent): void => {
event.preventDefault();
event.stopPropagation();
this.#commit(immutableDeepAssign(this.state, {
target: event.data.target,
frame: event.data.frame,
selectors: event.data.selectors.map(selector => typeof selector === 'string' ? [selector] : selector),
offsetX: event.data.offsetX,
offsetY: event.data.offsetY,
}));
};
#handleAddOrRemoveClick =
(assignments: DeepImmutable<DeepPartial<Assignments<EditorState>>>, query: string,
metric: Host.UserMetrics.RecordingEdited): ((event: Event) => void) => event => {
event.preventDefault();
event.stopPropagation();
this.#commit(immutableDeepAssign(this.state, assignments));
this.#ensureFocus(query);
if (metric) {
Host.userMetrics.recordingEdited(metric);
}
};
#handleKeyDownEvent = (event: Event): void => {
assert(event instanceof KeyboardEvent);
if (event.target instanceof RecorderInput && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
const elements = this.renderRoot.querySelectorAll('devtools-recorder-input');
const element = [...elements].findIndex(value => value === event.target);
if (element >= 0 && element + 1 < elements.length) {
elements[element + 1].focus();
} else {
event.target.blur();
}
}
};
#handleInputBlur = <A extends Attribute>(opts: {
attribute: A,
// If there are not assignments, then we should ignore the event.
from(this: StepEditor, value: DataType<A>): DeepImmutable<DeepPartial<Assignments<EditorState>>>|undefined,
metric: Host.UserMetrics.RecordingEdited,
}): ((event: Event) => void) => event => {
assert(event.target instanceof RecorderInput);
if (event.target.disabled) {
return;
}
const dataType = dataTypeByAttribute[opts.attribute];
const value = typeConverters[dataType](event.target.value) as DataType<A>;
const assignments = opts.from.bind(this)(value);
if (!assignments) {
return;
}
this.#commit(immutableDeepAssign(this.state, assignments));
if (opts.metric) {
Host.userMetrics.recordingEdited(opts.metric);
}
};
#handleTypeInputBlur = async(event: Event): Promise<void> => {
assert(event.target instanceof RecorderInput);
if (event.target.disabled) {
return;
}
const value = event.target.value as Models.Schema.StepType;
if (value === this.state.type) {
return;
}
if (!Object.values(Models.Schema.StepType).includes(value)) {
this.error = i18nString(UIStrings.unknownActionType);
return;
}
this.#commit(await EditorState.default(value));
Host.userMetrics.recordingEdited(Host.UserMetrics.RecordingEdited.TypeChanged);
};
#handleAddRowClickEvent = async(event: MouseEvent): Promise<void> => {
event.preventDefault();
event.stopPropagation();
const attribute = (event.target as HTMLElement).dataset.attribute as Attribute;
this.#commit(immutableDeepAssign(this.state, {
[attribute]: await EditorState.defaultByAttribute(this.state, attribute),
}));
this.#ensureFocus(`[data-attribute=${attribute}].attribute devtools-recorder-input`);
};
#renderInlineButton(opts: {class: string, title: string, iconUrl: string, onClick: (event: MouseEvent) => void}):
LitHtml.TemplateResult|undefined {
if (this.disabled) {
return;
}
return html`
<devtools-button
title=${opts.title}
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${opts.iconUrl}
.variant=${Buttons.Button.Variant.SECONDARY}
class="inline-button ${opts.class}"
@click=${opts.onClick}
></devtools-button>
`;
}
#renderDeleteButton(attribute: Attribute): LitHtml.TemplateResult|undefined {
if (this.disabled) {
return;
}
const attributes = attributesByType[this.state.type];
const optional = [...attributes.optional].includes(attribute as typeof attributes.optional[number]);
if (!optional || this.disabled) {
return;
}
// clang-format off
return html`<devtools-button
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${deleteIconUrl}
.variant=${Buttons.Button.Variant.SECONDARY}
.title=${i18nString(UIStrings.deleteRow)}
class="inline-button delete-row"
data-attribute=${attribute}
@click=${(event: MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
this.#commit(
immutableDeepAssign(this.state, { [attribute]: undefined }),
);
}}
></devtools-button>`;
// clang-format on
}
#renderTypeRow(editable: boolean): LitHtml.TemplateResult {
this.#renderedAttributes.add('type');
// clang-format off
return html`<div class="row attribute" data-attribute="type">
<div>type<span class="separator">:</span></div>
<devtools-recorder-input
.disabled=${!editable || this.disabled}
.options=${Object.values(Models.Schema.StepType)}
.placeholder=${defaultValuesByAttribute.type}
.value=${live(this.state.type)}
@blur=${this.#handleTypeInputBlur}
></devtools-recorder-input>
</div>`;
// clang-format on
}
#renderRow(attribute: Attribute): LitHtml.TemplateResult|undefined {
this.#renderedAttributes.add(attribute);
const attributeValue = this.state[attribute]?.toString();
if (attributeValue === undefined) {
return;
}
// clang-format off
return html`<div class="row attribute" data-attribute=${attribute}>
<div>${attribute}<span class="separator">:</span></div>
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute[attribute].toString()}
.value=${live(attributeValue)}
.mimeType=${((): string => {
switch (attribute) {
case 'expression':
return 'text/javascript';
case 'properties':
return 'application/json';
default:
return '';
}
})()}
@blur=${this.#handleInputBlur({
attribute,
from(value) {
if (this.state[attribute] === undefined) {
return;
}
switch (attribute) {
case 'properties':
Host.userMetrics.recordingAssertion(Host.UserMetrics.RecordingAssertion.PropertyAssertionEdited);
break;
}
return {[attribute]: value};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
${this.#renderDeleteButton(attribute)}
</div>`;
// clang-format on
}
#renderFrameRow(): LitHtml.TemplateResult|undefined {
this.#renderedAttributes.add('frame');
if (this.state.frame === undefined) {
return;
}
// clang-format off
return html`
<div class="attribute" data-attribute="frame">
<div class="row">
<div>frame<span class="separator">:</span></div>
${this.#renderDeleteButton('frame')}
</div>
${this.state.frame.map((frame, index, frames) => {
return html`
<div class="padded row">
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.frame[0].toString()}
.value=${live(frame.toString())}
data-path=${`frame.${index}`}
@blur=${this.#handleInputBlur({
attribute: 'frame',
from(value) {
if (this.state.frame?.[index] === undefined) {
return;
}
return {
frame: new ArrayAssignments({ [index]: value }),
};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
${this.#renderInlineButton({
class: 'add-frame',
title: i18nString(UIStrings.addFrameIndex),
iconUrl: plusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
frame: new ArrayAssignments({
[index + 1]: new InsertAssignment(
defaultValuesByAttribute.frame[0],
),
}),
},
`devtools-recorder-input[data-path="frame.${index + 1}"]`,
Host.UserMetrics.RecordingEdited.OtherEditing,
),
})}
${this.#renderInlineButton({
class: 'remove-frame',
title: i18nString(UIStrings.removeFrameIndex),
iconUrl: minusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
frame: new ArrayAssignments({ [index]: undefined }),
},
`devtools-recorder-input[data-path="frame.${Math.min(
index,
frames.length - 2,
)}"]`,
Host.UserMetrics.RecordingEdited.OtherEditing,
),
})}
</div>
`;
})}
</div>
`;
// clang-format on
}
#renderSelectorsRow(): LitHtml.TemplateResult|undefined {
this.#renderedAttributes.add('selectors');
if (this.state.selectors === undefined) {
return;
}
// clang-format off
return html`<div class="attribute" data-attribute="selectors">
<div class="row">
<div>selectors<span class="separator">:</span></div>
<devtools-recorder-selector-picker-button
@selectorpicked=${this.#handleSelectorPickedEvent}
.disabled=${this.disabled}
></devtools-recorder-selector-picker-button>
${this.#renderDeleteButton('selectors')}
</div>
${this.state.selectors.map((selector, index, selectors) => {
return html`<div class="padded row" data-selector-path=${index}>
<div>selector #${index + 1}<span class="separator">:</span></div>
${this.#renderInlineButton({
class: 'add-selector',
title: i18nString(UIStrings.addSelector),
iconUrl: plusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
selectors: new ArrayAssignments({
[index + 1]: new InsertAssignment(
structuredClone(defaultValuesByAttribute.selectors[0]),
),
}),
},
`devtools-recorder-input[data-path="selectors.${index + 1}.0"]`,
Host.UserMetrics.RecordingEdited.SelectorAdded,
),
})}
${this.#renderInlineButton({
class: 'remove-selector',
title: i18nString(UIStrings.removeSelector),
iconUrl: minusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{ selectors: new ArrayAssignments({ [index]: undefined }) },
`devtools-recorder-input[data-path="selectors.${Math.min(
index,
selectors.length - 2,
)}.0"]`,
Host.UserMetrics.RecordingEdited.SelectorRemoved,
),
})}
</div>
${selector.map((part, partIndex, parts) => {
return html`<div
class="double padded row"
data-selector-path="${index}.${partIndex}"
>
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.selectors[0][0]}
.value=${live(part)}
data-path=${`selectors.${index}.${partIndex}`}
@blur=${this.#handleInputBlur({
attribute: 'selectors',
from(value) {
if (
this.state.selectors?.[index]?.[partIndex] === undefined
) {
return;
}
return {
selectors: new ArrayAssignments({
[index]: new ArrayAssignments({
[partIndex]: value,
}),
}),
};
},
metric: Host.UserMetrics.RecordingEdited.SelectorPartEdited,
})}
></devtools-recorder-input>
${this.#renderInlineButton({
class: 'add-selector-part',
title: i18nString(UIStrings.addSelectorPart),
iconUrl: plusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
selectors: new ArrayAssignments({
[index]: new ArrayAssignments({
[partIndex + 1]: new InsertAssignment(
defaultValuesByAttribute.selectors[0][0],
),
}),
}),
},
`devtools-recorder-input[data-path="selectors.${index}.${
partIndex + 1
}"]`,
Host.UserMetrics.RecordingEdited.SelectorPartAdded,
),
})}
${this.#renderInlineButton({
class: 'remove-selector-part',
title: i18nString(UIStrings.removeSelectorPart),
iconUrl: minusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
selectors: new ArrayAssignments({
[index]: new ArrayAssignments({
[partIndex]: undefined,
}),
}),
},
`devtools-recorder-input[data-path="selectors.${index}.${Math.min(
partIndex,
parts.length - 2,
)}"]`,
Host.UserMetrics.RecordingEdited.SelectorPartRemoved,
),
})}
</div>`;
})}`;
})}
</div>`;
// clang-format on
}
#renderAssertedEvents(): LitHtml.TemplateResult|undefined {
this.#renderedAttributes.add('assertedEvents');
if (this.state.assertedEvents === undefined) {
return;
}
// clang-format off
return html`<div class="attribute" data-attribute="assertedEvents">
<div class="row">
<div>asserted events<span class="separator">:</span></div>
${this.#renderDeleteButton('assertedEvents')}
</div>
${this.state.assertedEvents.map((event, index) => {
return html` <div class="padded row">
<div>type<span class="separator">:</span></div>
<div>${event.type}</div>
</div>
<div class="padded row">
<div>title<span class="separator">:</span></div>
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.assertedEvents[0].title}
.value=${live(event.title ?? '')}
@blur=${this.#handleInputBlur({
attribute: 'assertedEvents',
from(value) {
if (this.state.assertedEvents?.[index]?.title === undefined) {
return;
}
return {
assertedEvents: new ArrayAssignments({
[index]: { title: value },
}),
};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
</div>
<div class="padded row">
<div>url<span class="separator">:</span></div>
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.assertedEvents[0].url}
.value=${live(event.url ?? '')}
@blur=${this.#handleInputBlur({
attribute: 'url',
from(value) {
if (this.state.assertedEvents?.[index]?.url === undefined) {
return;
}
return {
assertedEvents: new ArrayAssignments({
[index]: { url: value },
}),
};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
</div>`;
})}
</div> `;
// clang-format on
}
#renderAttributesRow(): LitHtml.TemplateResult|undefined {
this.#renderedAttributes.add('attributes');
if (this.state.attributes === undefined) {
return;
}
// clang-format off
return html`<div class="attribute" data-attribute="attributes">
<div class="row">
<div>attributes<span class="separator">:</span></div>
${this.#renderDeleteButton('attributes')}
</div>
${this.state.attributes.map(({ name, value }, index, attributes) => {
return html`<div class="padded row">
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.attributes[0].name}
.value=${live(name)}
data-path=${`attributes.${index}.name`}
@blur=${this.#handleInputBlur({
attribute: 'attributes',
from(name) {
if (this.state.attributes?.[index]?.name === undefined) {
return;
}
Host.userMetrics.recordingAssertion(
Host.UserMetrics.RecordingAssertion.AttributeAssertionEdited,
);
return {
attributes: new ArrayAssignments({ [index]: { name } }),
};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
<span class="separator">:</span>
<devtools-recorder-input
.disabled=${this.disabled}
.placeholder=${defaultValuesByAttribute.attributes[0].value}
.value=${live(value)}
data-path=${`attributes.${index}.value`}
@blur=${this.#handleInputBlur({
attribute: 'attributes',
from(value) {
if (this.state.attributes?.[index]?.value === undefined) {
return;
}
Host.userMetrics.recordingAssertion(
Host.UserMetrics.RecordingAssertion.AttributeAssertionEdited,
);
return {
attributes: new ArrayAssignments({ [index]: { value } }),
};
},
metric: Host.UserMetrics.RecordingEdited.OtherEditing,
})}
></devtools-recorder-input>
${this.#renderInlineButton({
class: 'add-attribute-assertion',
title: i18nString(UIStrings.addSelectorPart),
iconUrl: plusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{
attributes: new ArrayAssignments({
[index + 1]: new InsertAssignment(
((): { name: string, value: string } => {
{
const names = new Set(
attributes.map(({ name }) => name),
);
const defaultAttribute =
defaultValuesByAttribute.attributes[0];
let name = defaultAttribute.name;
let i = 0;
while (names.has(name)) {
++i;
name = `${defaultAttribute.name}-${i}`;
}
return { ...defaultAttribute, name };
}
})(),
),
}),
},
`devtools-recorder-input[data-path="attributes.${
index + 1
}.name"]`,
Host.UserMetrics.RecordingEdited.OtherEditing,
),
})}
${this.#renderInlineButton({
class: 'remove-attribute-assertion',
title: i18nString(UIStrings.removeSelectorPart),
iconUrl: minusIconUrl,
onClick: this.#handleAddOrRemoveClick(
{ attributes: new ArrayAssignments({ [index]: undefined }) },
`devtools-recorder-input[data-path="attributes.${Math.min(
index,
attributes.length - 2,
)}.value"]`,
Host.UserMetrics.RecordingEdited.OtherEditing,
),
})}
</div>`;
})}
</div>`;
// clang-format on
}
#renderAddRowButtons(): Array<LitHtml.TemplateResult|undefined> {
const attributes = attributesByType[this.state.type];
return [...attributes.optional].filter(attr => this.state[attr] === undefined).map(attr => {
// clang-format off
return html`<devtools-button
.variant=${Buttons.Button.Variant.SECONDARY}
class="add-row"
data-attribute=${attr}
@click=${this.#handleAddRowClickEvent}
>
${i18nString(UIStrings.addAttribute, {
attributeName: attr,
})}
</devtools-button>`;
// clang-format on
});
}
#ensureFocus = (query: string): void => {
void this.updateComplete.then(() => {
const node = this.renderRoot.querySelector<HTMLElement>(query);
node?.focus();
});
};
protected override render(): LitHtml.TemplateResult {
this.#renderedAttributes = new Set();
// clang-format off
const result = html`
<div class="wrapper">
${this.#renderTypeRow(this.isTypeEditable)} ${this.#renderRow('target')}
${this.#renderFrameRow()} ${this.#renderSelectorsRow()}
${this.#renderRow('deviceType')} ${this.#renderRow('button')}
${this.#renderRow('url')} ${this.#renderRow('x')}
${this.#renderRow('y')} ${this.#renderRow('offsetX')}
${this.#renderRow('offsetY')} ${this.#renderRow('value')}
${this.#renderRow('key')} ${this.#renderRow('operator')}
${this.#renderRow('count')} ${this.#renderRow('expression')}
${this.#renderRow('duration')} ${this.#renderAssertedEvents()}
${this.#renderRow('timeout')} ${this.#renderRow('width')}
${this.#renderRow('height')} ${this.#renderRow('deviceScaleFactor')}
${this.#renderRow('isMobile')} ${this.#renderRow('hasTouch')}
${this.#renderRow('isLandscape')} ${this.#renderRow('download')}
${this.#renderRow('upload')} ${this.#renderRow('latency')}
${this.#renderRow('name')} ${this.#renderRow('parameters')}
${this.#renderRow('visible')} ${this.#renderRow('properties')}
${this.#renderAttributesRow()}
${this.error
? html`
<div class="error">
${i18nString(UIStrings.notSaved, {
error: this.error,
})}
</div>
`
: undefined}
${!this.disabled
? html`<div
class="row-buttons wrapped gap row regular-font no-margin"
>
${this.#renderAddRowButtons()}
</div>`
: undefined}
</div>
`;
// clang-format on
Platform.DCHECK(() => {
for (const key of Object.keys(dataTypeByAttribute)) {
if (!this.#renderedAttributes.has(key as Attribute)) {
return false;
}
}
return true;
}, 'One of the editable attributes does not have UI');
return result;
}
}