UNPKG

chrome-devtools-frontend

Version:
1,167 lines (1,106 loc) • 40.7 kB
// Copyright 2023 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 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 SuggestionInput from '../../ui/components/suggestion_input/suggestion_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 * as Models from './models/models.js'; import {RequestSelectorAttributeEvent, SelectorPicker} from './SelectorPicker.js'; import stepEditorStyles from './stepEditor.css.js'; import { ArrayAssignments, assert, type Assignments, deepFreeze, type DeepImmutable, type DeepPartial, immutableDeepAssign, InsertAssignment, type Keys, type OptionalKeys, type RequiredKeys, SharedObject, } from './util/util.js'; const {html, render, Directives} = Lit; const {live} = Directives; const {widget} = UI.Widget; 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) => value.trim(), number: (value: string) => { const number = parseFloat(value); if (Number.isNaN(number)) { return 0; } return number; }, boolean: (value: string) => { 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: Array<Exclude<RequiredKeys<StepFor<Type>>, 'type'>>, optional: Array<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 displayed 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 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 displayed when a user enters a type in the input that is not associated with any existing types. */ unknownActionType: 'Enter a valid action type', } as const; const str_ = i18n.i18n.registerUIStrings('panels/recorder/StepEditor.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); 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: SharedObject.SharedObject<Puppeteer> = new 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 () => 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 await this.#puppeteer.run<unknown>(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) .then(h => h || defaultValuesByAttribute.height); } case 'width': { return puppeteer.page.evaluate(() => (visualViewport as VisualViewport).width) .then(w => w || defaultValuesByAttribute.width); } default: { return defaultValuesByAttribute[attribute]; } } }); } static fromStep(step: 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); } 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?.length === 0 && 'frame' in step) { delete step.frame; } return cleanUndefineds(Models.SchemaUtils.parseStep(step)); } } /** * @fires RequestSelectorAttributeEvent#requestselectorattribute * @fires StepEditedEvent#stepedited */ export interface ViewInput { state: DeepImmutable<EditorState>; disabled: boolean; error: string|undefined; isTypeEditable: boolean; handleInputBlur: <A extends Attribute>(opts: { attribute: A, from(value: DataType<A>): DeepImmutable<DeepPartial<Assignments<EditorState>>>|undefined, }) => (event: Event) => void; handleTypeInputBlur: (event: Event) => Promise<void>; handleAddRowClickEvent: (event: MouseEvent) => Promise<void>; handleDeleteRowClick: (attribute: Attribute) => (event: MouseEvent) => void; handleSelectorPicked: (data: Models.Schema.StepWithSelectors& Pick<Models.Schema.ClickAttributes, 'offsetX'|'offsetY'>) => void; handleAttributeRequested: (send: (attribute?: string) => void) => void; handleAddOrRemoveClick: (assignments: DeepImmutable<DeepPartial<Assignments<EditorState>>>, query: string) => (event: Event) => void; handleKeyDownEvent: (event: Event) => void; } function renderInlineButton( input: ViewInput, opts: {class: string, title: string, iconName: string, onClick: (event: MouseEvent) => void}): Lit.TemplateResult|undefined { if (input.disabled) { return; } return html` <devtools-button title=${opts.title} .accessibleLabel=${opts.title} .size=${Buttons.Button.Size.SMALL} .iconName=${opts.iconName} .variant=${Buttons.Button.Variant.ICON} jslog=${VisualLogging.action(opts.class).track({ click: true, })} class="inline-button ${opts.class}" @click=${opts.onClick} ></devtools-button> `; } function renderDeleteButton(input: ViewInput, attribute: Attribute): Lit.TemplateResult|undefined { if (input.disabled) { return; } const attributes = attributesByType[input.state.type]; const optional = [...attributes.optional].includes(attribute as typeof attributes.optional[number]); if (!optional || input.disabled) { return; } // clang-format off return html`<devtools-button .size=${Buttons.Button.Size.SMALL} .iconName=${'bin'} .variant=${Buttons.Button.Variant.ICON} .title=${i18nString(UIStrings.deleteRow)} class="inline-button delete-row" data-attribute=${attribute} jslog=${VisualLogging.action('delete').track({click: true})} @click=${input.handleDeleteRowClick(attribute)} ></devtools-button>`; // clang-format on } type View = (input: ViewInput, _output: undefined, target: HTMLElement) => void; const DEFAULT_VIEW: View = (input, _output, target) => { const renderedAttributes = new Set<Attribute>(); function renderTypeRow(editable: boolean): Lit.TemplateResult { renderedAttributes.add('type'); // clang-format off return html`<div class="row attribute" data-attribute="type" jslog=${VisualLogging.treeItem('type').track({resize: true})}> <div id="type">type<span class="separator">:</span></div> <devtools-suggestion-input aria-labelledby="type" .disabled=${!editable || input.disabled} .options=${Object.values(Models.Schema.StepType)} .placeholder=${defaultValuesByAttribute.type} .value=${live(input.state.type)} @blur=${input.handleTypeInputBlur} ></devtools-suggestion-input> </div>`; // clang-format on } function renderRow(attribute: Attribute): Lit.TemplateResult|undefined { renderedAttributes.add(attribute); const attributeValue = input.state[attribute]?.toString(); if (attributeValue === undefined) { return; } // clang-format off return html`<div class="row attribute" data-attribute=${attribute} jslog=${VisualLogging.treeItem(Platform.StringUtilities.toKebabCase(attribute)).track({resize: true})}> <div id=${attribute}>${attribute}<span class="separator">:</span></div> <devtools-suggestion-input .disabled=${input.disabled} aria-labelledby=${attribute} .placeholder=${defaultValuesByAttribute[attribute].toString()} .value=${live(attributeValue)} .mimeType=${(() => { switch (attribute) { case 'expression': return 'text/javascript'; case 'properties': return 'application/json'; default: return ''; } })()} @blur=${input.handleInputBlur({ attribute, from(value) { if (input.state[attribute] === undefined || input.state[attribute] === value) { return; } return {[attribute]: value}; }, })} ></devtools-suggestion-input> ${renderDeleteButton(input, attribute)} </div>`; // clang-format on } function renderFrameRow(): Lit.TemplateResult|undefined { renderedAttributes.add('frame'); if (input.state.frame === undefined) { return; } // clang-format off return html` <div class="attribute" data-attribute="frame" jslog=${VisualLogging.treeItem('frame').track({resize: true})}> <div class="row"> <div id="frame">frame<span class="separator">:</span></div> ${renderDeleteButton(input, 'frame')} </div> ${input.state.frame.map((frame, index, frames) => { return html` <div class="padded row"> <devtools-suggestion-input aria-labelledby="frame" .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.frame[0].toString()} .value=${live(frame.toString())} data-path=${`frame.${index}`} @blur=${input.handleInputBlur({ attribute: 'frame', from(value) { if (input.state.frame?.[index] === undefined || input.state.frame[index] === value) { return; } return { frame: new ArrayAssignments({ [index]: value }), }; }, })} ></devtools-suggestion-input> ${renderInlineButton(input, { class: 'add-frame', title: i18nString(UIStrings.addFrameIndex), iconName: 'plus', onClick: input.handleAddOrRemoveClick( { frame: new ArrayAssignments({ [index + 1]: new InsertAssignment( defaultValuesByAttribute.frame[0], ), }), }, `devtools-suggestion-input[data-path="frame.${index + 1}"]`, ), })} ${renderInlineButton(input, { class: 'remove-frame', title: i18nString(UIStrings.removeFrameIndex), iconName: 'minus', onClick: input.handleAddOrRemoveClick( { frame: new ArrayAssignments({ [index]: undefined }), }, `devtools-suggestion-input[data-path="frame.${Math.min( index, frames.length - 2, )}"]`, ), })} </div> `; })} </div> `; // clang-format on } function renderSelectorsRow(): Lit.TemplateResult|undefined { renderedAttributes.add('selectors'); if (input.state.selectors === undefined) { return; } // clang-format off return html`<div class="attribute" data-attribute="selectors" jslog=${VisualLogging.treeItem('selectors')}> <div class="row"> <div>selectors<span class="separator">:</span></div> ${widget(SelectorPicker, { disabled: input.disabled, onSelectorPicked: input.handleSelectorPicked, onAttributeRequested: input.handleAttributeRequested, })} ${renderDeleteButton(input, 'selectors')} </div> ${input.state.selectors.map((selector, index, selectors) => { return html`<div class="padded row" data-selector-path=${index}> <div id="selector-${index}">selector #${index + 1}<span class="separator">:</span></div> ${renderInlineButton(input, { class: 'add-selector', title: i18nString(UIStrings.addSelector), iconName: 'plus', onClick: input.handleAddOrRemoveClick( { selectors: new ArrayAssignments({ [index + 1]: new InsertAssignment( structuredClone(defaultValuesByAttribute.selectors[0]), ), }), }, `devtools-suggestion-input[data-path="selectors.${index + 1}.0"]`, ), })} ${renderInlineButton(input, { class: 'remove-selector', title: i18nString(UIStrings.removeSelector), iconName: 'minus', onClick: input.handleAddOrRemoveClick( { selectors: new ArrayAssignments({ [index]: undefined }) }, `devtools-suggestion-input[data-path="selectors.${Math.min( index, selectors.length - 2, )}.0"]`, ), })} </div> ${selector.map((part, partIndex, parts) => { return html`<div class="double padded row" data-selector-path="${index}.${partIndex}" > <devtools-suggestion-input aria-labelledby="selector-${index}" .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.selectors[0][0]} .value=${live(part)} data-path=${`selectors.${index}.${partIndex}`} @blur=${input.handleInputBlur({ attribute: 'selectors', from(value) { if ( input.state.selectors?.[index]?.[partIndex] === undefined || input.state.selectors[index][partIndex] === value ) { return; } return { selectors: new ArrayAssignments({ [index]: new ArrayAssignments({ [partIndex]: value, }), }), }; }, })} ></devtools-suggestion-input> ${renderInlineButton(input, { class: 'add-selector-part', title: i18nString(UIStrings.addSelectorPart), iconName: 'plus', onClick: input.handleAddOrRemoveClick( { selectors: new ArrayAssignments({ [index]: new ArrayAssignments({ [partIndex + 1]: new InsertAssignment( defaultValuesByAttribute.selectors[0][0], ), }), }), }, `devtools-suggestion-input[data-path="selectors.${index}.${ partIndex + 1 }"]`, ), })} ${renderInlineButton(input, { class: 'remove-selector-part', title: i18nString(UIStrings.removeSelectorPart), iconName: 'minus', onClick: input.handleAddOrRemoveClick( { selectors: new ArrayAssignments({ [index]: new ArrayAssignments({ [partIndex]: undefined, }), }), }, `devtools-suggestion-input[data-path="selectors.${index}.${Math.min( partIndex, parts.length - 2, )}"]`, ), })} </div>`; })}`; })} </div>`; // clang-format on } function renderAssertedEvents(): Lit.TemplateResult|undefined { renderedAttributes.add('assertedEvents'); if (input.state.assertedEvents === undefined) { return; } // clang-format off return html`<div class="attribute" data-attribute="assertedEvents" jslog=${VisualLogging.treeItem('asserted-events')}> <div class="row"> <div>asserted events<span class="separator">:</span></div> ${renderDeleteButton(input, 'assertedEvents')} </div> ${input.state.assertedEvents.map((event, index) => { return html` <div class="padded row" jslog=${VisualLogging.treeItem('event-type')}> <div id="event-type">type<span class="separator">:</span></div> <div aria-labelledby="event-type">${event.type}</div> </div> <div class="padded row" jslog=${VisualLogging.treeItem('event-title')}> <div id="event-title">title<span class="separator">:</span></div> <devtools-suggestion-input aria-labelledby="event-title" .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.assertedEvents[0].title} .value=${live(event.title ?? '')} @blur=${input.handleInputBlur({ attribute: 'assertedEvents', from(value) { if (input.state.assertedEvents?.[index]?.title === undefined || input.state.assertedEvents[index].title === value) { return; } return { assertedEvents: new ArrayAssignments({ [index]: { title: value }, }), }; }, })} ></devtools-suggestion-input> </div> <div id="event-url" class="padded row" jslog=${VisualLogging.treeItem('event-url')}> <div>url<span class="separator">:</span></div> <devtools-suggestion-input aria-labelledby="event-url" .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.assertedEvents[0].url} .value=${live(event.url ?? '')} @blur=${input.handleInputBlur({ attribute: 'url', from(value) { if (input.state.assertedEvents?.[index]?.url === undefined || input.state.assertedEvents[index].url === value) { return; } return { assertedEvents: new ArrayAssignments({ [index]: { url: value }, }), }; }, })} ></devtools-suggestion-input> </div>`; })} </div> `; // clang-format on } function renderAttributesRow(): Lit.TemplateResult|undefined { renderedAttributes.add('attributes'); if (input.state.attributes === undefined) { return; } // clang-format off return html`<div class="attribute" data-attribute="attributes" jslog=${VisualLogging.treeItem('attributes')}> <div class="row"> <div>attributes<span class="separator">:</span></div> ${renderDeleteButton(input, 'attributes')} </div> ${input.state.attributes.map(({ name, value }, index, attributes) => { return html`<div class="padded row" jslog=${VisualLogging.treeItem('attribute')}> <devtools-suggestion-input .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.attributes[0].name} .value=${live(name)} data-path=${`attributes.${index}.name`} jslog=${VisualLogging.key().track({change: true})} @blur=${input.handleInputBlur({ attribute: 'attributes', from(name) { if (input.state.attributes?.[index]?.name === undefined || input.state.attributes[index].name === name) { return; } return { attributes: new ArrayAssignments({ [index]: { name } }), }; }, })} ></devtools-suggestion-input> <span class="separator">:</span> <devtools-suggestion-input .disabled=${input.disabled} .placeholder=${defaultValuesByAttribute.attributes[0].value} .value=${live(value)} data-path=${`attributes.${index}.value`} @blur=${input.handleInputBlur({ attribute: 'attributes', from(value) { if (input.state.attributes?.[index]?.value === undefined || input.state.attributes[index].value === value) { return; } return { attributes: new ArrayAssignments({ [index]: { value } }), }; }, })} ></devtools-suggestion-input> ${renderInlineButton(input, { class: 'add-attribute-assertion', title: i18nString(UIStrings.addSelectorPart), iconName: 'plus', onClick: input.handleAddOrRemoveClick( { attributes: new ArrayAssignments({ [index + 1]: new InsertAssignment( (() => { { 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-suggestion-input[data-path="attributes.${ index + 1 }.name"]`, ), })} ${renderInlineButton(input, { class: 'remove-attribute-assertion', title: i18nString(UIStrings.removeSelectorPart), iconName: 'minus', onClick: input.handleAddOrRemoveClick( { attributes: new ArrayAssignments({ [index]: undefined }) }, `devtools-suggestion-input[data-path="attributes.${Math.min( index, attributes.length - 2, )}.value"]`, ), })} </div>`; })} </div>`; // clang-format on } function renderAddRowButtons(): Array<Lit.TemplateResult|undefined> { const attributes = attributesByType[input.state.type]; return [...attributes.optional].filter(attr => input.state[attr] === undefined).map(attr => { // clang-format off return html`<devtools-button .variant=${Buttons.Button.Variant.OUTLINED} class="add-row" data-attribute=${attr} jslog=${VisualLogging.action(`add-${Platform.StringUtilities.toKebabCase(attr)}`)} @click=${input.handleAddRowClickEvent} > ${i18nString(UIStrings.addAttribute, { attributeName: attr, })} </devtools-button>`; // clang-format on }); } // clang-format off const result = html` <style>${stepEditorStyles}</style> <div class="wrapper" jslog=${VisualLogging.tree('step-editor')} > ${renderTypeRow(input.isTypeEditable)} ${renderRow('target')} ${renderFrameRow()} ${renderSelectorsRow()} ${renderRow('deviceType')} ${renderRow('button')} ${renderRow('url')} ${renderRow('x')} ${renderRow('y')} ${renderRow('offsetX')} ${renderRow('offsetY')} ${renderRow('value')} ${renderRow('key')} ${renderRow('operator')} ${renderRow('count')} ${renderRow('expression')} ${renderRow('duration')} ${renderAssertedEvents()} ${renderRow('timeout')} ${renderRow('width')} ${renderRow('height')} ${renderRow('deviceScaleFactor')} ${renderRow('isMobile')} ${renderRow('hasTouch')} ${renderRow('isLandscape')} ${renderRow('download')} ${renderRow('upload')} ${renderRow('latency')} ${renderRow('name')} ${renderRow('parameters')} ${renderRow('visible')} ${renderRow('properties')} ${renderAttributesRow()} ${input.error ? html` <div class="error"> ${i18nString(UIStrings.notSaved, { error: input.error, })} </div> ` : undefined} ${!input.disabled ? html`<div class="row-buttons wrapped gap row regular-font no-margin" > ${renderAddRowButtons()} </div>` : undefined} </div> `; // clang-format on for (const key of Object.keys(dataTypeByAttribute)) { if (!renderedAttributes.has(key as Attribute)) { throw new Error(`The editable attribute ${key} does not have UI`); } } render(result, target, {container: {listeners: {keydown: input.handleKeyDownEvent}}}); }; export class StepEditor extends UI.Widget.Widget { #state: DeepImmutable<EditorState>; #error: string|undefined; #isTypeEditable = true; #disabled = false; #view: View; constructor(element?: HTMLElement, view = DEFAULT_VIEW) { super(element, {useShadowDom: true}); this.#state = {type: Models.Schema.StepType.WaitForElement}; this.#view = view; } set isTypeEditable(value: boolean) { this.#isTypeEditable = value; this.requestUpdate(); } set disabled(value: boolean) { this.#disabled = value; this.requestUpdate(); } set step(step: Models.Schema.Step) { this.#state = deepFreeze(EditorState.fromStep(step)); this.#error = undefined; this.requestUpdate(); } override performUpdate(): void { const input: ViewInput = { state: this.#state, disabled: this.#disabled, error: this.#error, isTypeEditable: this.#isTypeEditable, handleInputBlur: this.#handleInputBlur, handleTypeInputBlur: this.#handleTypeInputBlur, handleAddRowClickEvent: this.#handleAddRowClickEvent, handleDeleteRowClick: this.#handleDeleteRowClick, handleSelectorPicked: this.#handleSelectorPicked, handleAttributeRequested: this.#handleAttributeRequested, handleAddOrRemoveClick: this.#handleAddOrRemoveClick, handleKeyDownEvent: this.#handleKeyDownEvent, }; this.#view(input, undefined, this.contentElement); } #commit(updatedState: DeepImmutable<EditorState>): void { try { this.element.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; } this.requestUpdate(); } #handleSelectorPicked = (data: Models.Schema.StepWithSelectors&Pick<Models.Schema.ClickAttributes, 'offsetX'|'offsetY'>): void => { this.#commit(immutableDeepAssign(this.#state, { target: data.target, frame: data.frame, selectors: data.selectors.map(selector => typeof selector === 'string' ? [selector] : selector), offsetX: data.offsetX, offsetY: data.offsetY, })); }; #handleAttributeRequested = (send: (attribute?: string) => void): void => { this.element.dispatchEvent(new RequestSelectorAttributeEvent(send)); }; #handleAddOrRemoveClick = (assignments: DeepImmutable<DeepPartial<Assignments<EditorState>>>, query: string): ((event: Event) => void) => event => { event.preventDefault(); event.stopPropagation(); this.#commit(immutableDeepAssign(this.#state, assignments)); this.#ensureFocus(query); }; #handleDeleteRowClick = (attribute: Attribute) => (event: Event): void => { event.preventDefault(); event.stopPropagation(); this.#commit(immutableDeepAssign(this.#state, {[attribute]: undefined})); }; #ensureFocus = (query: string): void => { void this.updateComplete.then(() => { const node = this.contentElement.querySelector<HTMLElement>(query); node?.focus(); }); }; #handleKeyDownEvent = (event: Event): void => { assert(event instanceof KeyboardEvent); if (event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput && event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); const elements = this.contentElement.querySelectorAll('devtools-suggestion-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, }): ((event: Event) => void) => event => { assert(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput); 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)); }; #handleTypeInputBlur = async(event: Event): Promise<void> => { assert(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput); 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); this.requestUpdate(); return; } this.#commit(await EditorState.default(value)); }; #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-suggestion-input`); }; }