chrome-devtools-frontend
Version:
Chrome DevTools UI
1,218 lines (1,101 loc) • 45.5 kB
text/typescript
// 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 '../../ui/components/icon_button/icon_button.js';
import '../../ui/components/menus/menus.js';
import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as Dialogs from '../../ui/components/dialogs/dialogs.js';
import type * as Menus from '../../ui/components/menus/menus.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 ElementsComponents from '../elements/components/components.js';
import editorWidgetStyles from './JSONEditor.css.js';
const {html, render, Directives, nothing} = Lit;
const {live, classMap, repeat} = Directives;
const UIStrings = {
/**
*@description The title of a button that deletes a parameter.
*/
deleteParameter: 'Delete parameter',
/**
*@description The title of a button that adds a parameter.
*/
addParameter: 'Add a parameter',
/**
*@description The title of a button that reset the value of a paremeters to its default value.
*/
resetDefaultValue: 'Reset to default value',
/**
*@description The title of a button to add custom key/value pairs to object parameters with no keys defined
*/
addCustomProperty: 'Add custom property',
/**
* @description The title of a the button that sends a CDP command.
*/
sendCommandCtrlEnter: 'Send command - Ctrl+Enter',
/**
* @description The title of a the button that sends a CDP command.
*/
sendCommandCmdEnter: 'Send command - ⌘+Enter',
/**
* @description he title of a the button that copies a CDP command.
*/
copyCommand: 'Copy command',
};
const str_ = i18n.i18n.registerUIStrings('panels/protocol_monitor/JSONEditor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export const enum ParameterType {
STRING = 'string',
NUMBER = 'number',
BOOLEAN = 'boolean',
ARRAY = 'array',
OBJECT = 'object',
}
interface BaseParameter {
optional: boolean;
name: string;
typeRef?: string;
description: string;
isCorrectType?: boolean;
isKeyEditable?: boolean;
}
interface ArrayParameter extends BaseParameter {
type: ParameterType.ARRAY;
value?: Parameter[];
}
interface NumberParameter extends BaseParameter {
type: ParameterType.NUMBER;
value?: number;
}
interface StringParameter extends BaseParameter {
type: ParameterType.STRING;
value?: string;
}
interface BooleanParameter extends BaseParameter {
type: ParameterType.BOOLEAN;
value?: boolean;
}
interface ObjectParameter extends BaseParameter {
type: ParameterType.OBJECT;
value?: Parameter[];
}
export type Parameter = ArrayParameter|NumberParameter|StringParameter|BooleanParameter|ObjectParameter;
export interface Command {
command: string;
parameters: {[x: string]: unknown};
targetId?: string;
}
const splitDescription = (description: string): [string, string] => {
// If the description is too long we make the UI a bit better by highlighting the first sentence
// which contains the most informations.
// The number 150 has been chosen arbitrarily
if (description.length > 150) {
const [firstSentence, restOfDescription] = description.split('.');
// To make the UI nicer, we add a dot at the end of the first sentence.
firstSentence + '.';
return [firstSentence, restOfDescription];
}
return [description, ''];
};
const defaultValueByType = new Map<string, string|number|boolean>([
['string', ''],
['number', 0],
['boolean', false],
]);
const DUMMY_DATA = 'dummy';
const EMPTY_STRING = '<empty_string>';
export function suggestionFilter(option: string, query: string): boolean {
return option.toLowerCase().includes(query.toLowerCase());
}
export const enum Events {
SUBMIT_EDITOR = 'submiteditor',
}
export interface EventTypes {
[Events.SUBMIT_EDITOR]: Command;
}
export class JSONEditor extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.VBox>(UI.Widget.VBox) {
#metadataByCommand: Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>;
#typesByName: Map<string, Parameter[]>;
#enumsByName: Map<string, Record<string, string>>;
#parameters: Parameter[] = [];
#targets: SDK.Target.Target[] = [];
#command: string = '';
#targetId?: string;
#hintPopoverHelper?: UI.PopoverHelper.PopoverHelper;
constructor(
metadataByCommand: Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>,
typesByName: Map<string, Parameter[]>,
enumsByName: Map<string, Record<string, string>>,
) {
super(/* useShadowDom=*/ true);
this.registerRequiredCSS(editorWidgetStyles);
this.#metadataByCommand = metadataByCommand;
this.#typesByName = typesByName;
this.#enumsByName = enumsByName;
this.contentElement.addEventListener('keydown', event => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
this.#handleParameterInputKeydown(event);
this.#handleCommandSend();
}
});
}
get metadataByCommand(): Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}> {
return this.#metadataByCommand;
}
set metadataByCommand(metadataByCommand:
Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>) {
this.#metadataByCommand = metadataByCommand;
this.requestUpdate();
}
get typesByName(): Map<string, Parameter[]> {
return this.#typesByName;
}
set typesByName(typesByName: Map<string, Parameter[]>) {
this.#typesByName = typesByName;
this.requestUpdate();
}
get enumsByName(): Map<string, Record<string, string>> {
return this.#enumsByName;
}
set enumsByName(enumsByName: Map<string, Record<string, string>>) {
this.#enumsByName = enumsByName;
this.requestUpdate();
}
get parameters(): Parameter[] {
return this.#parameters;
}
set parameters(parameters: Parameter[]) {
this.#parameters = parameters;
this.requestUpdate();
}
get targets(): SDK.Target.Target[] {
return this.#targets;
}
set targets(targets: SDK.Target.Target[]) {
this.#targets = targets;
this.requestUpdate();
}
get command(): string {
return this.#command;
}
set command(command: string) {
if (this.#command !== command) {
this.#command = command;
this.requestUpdate();
}
}
get targetId(): string|undefined {
return this.#targetId;
}
set targetId(targetId: string|undefined) {
if (this.#targetId !== targetId) {
this.#targetId = targetId;
this.requestUpdate();
}
}
override wasShown(): void {
super.wasShown();
this.#hintPopoverHelper = new UI.PopoverHelper.PopoverHelper(
this.contentElement, event => this.#handlePopoverDescriptions(event), 'protocol-monitor.hint');
this.#hintPopoverHelper.setDisableOnClick(true);
this.#hintPopoverHelper.setTimeout(300);
this.#hintPopoverHelper.setHasPadding(true);
const targetManager = SDK.TargetManager.TargetManager.instance();
targetManager.addEventListener(
SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, this.#handleAvailableTargetsChanged, this);
this.#handleAvailableTargetsChanged();
this.requestUpdate();
}
override willHide(): void {
super.willHide();
this.#hintPopoverHelper?.hidePopover();
this.#hintPopoverHelper?.dispose();
const targetManager = SDK.TargetManager.TargetManager.instance();
targetManager.removeEventListener(
SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, this.#handleAvailableTargetsChanged, this);
}
#handleAvailableTargetsChanged(): void {
this.targets = SDK.TargetManager.TargetManager.instance().targets();
if (this.targets.length && this.targetId === undefined) {
this.targetId = this.targets[0].id();
}
}
getParameters(): {[key: string]: unknown} {
const formatParameterValue = (parameter: Parameter): unknown => {
if (parameter.value === undefined) {
return;
}
switch (parameter.type) {
case ParameterType.NUMBER: {
return Number(parameter.value);
}
case ParameterType.BOOLEAN: {
return Boolean(parameter.value);
}
case ParameterType.OBJECT: {
const nestedParameters: {[key: string]: unknown} = {};
for (const subParameter of parameter.value) {
const formattedValue = formatParameterValue(subParameter);
if (formattedValue !== undefined) {
nestedParameters[subParameter.name] = formatParameterValue(subParameter);
}
}
if (Object.keys(nestedParameters).length === 0) {
return undefined;
}
return nestedParameters;
}
case ParameterType.ARRAY: {
const nestedArrayParameters = [];
for (const subParameter of parameter.value) {
nestedArrayParameters.push(formatParameterValue(subParameter));
}
return nestedArrayParameters.length === 0 ? [] : nestedArrayParameters;
}
default: {
return parameter.value;
}
}
};
const formattedParameters: {[key: string]: unknown} = {};
for (const parameter of this.parameters) {
formattedParameters[parameter.name] = formatParameterValue(parameter);
}
return formatParameterValue({
type: ParameterType.OBJECT,
name: DUMMY_DATA,
optional: true,
value: this.parameters,
description: '',
}) as {[key: string]: unknown};
}
// Displays a command entered in the input bar inside the editor
displayCommand(command: string, parameters: Record<string, unknown>, targetId?: string): void {
this.targetId = targetId;
this.command = command;
const schema = this.metadataByCommand.get(this.command);
if (!schema?.parameters) {
return;
}
this.populateParametersForCommandWithDefaultValues();
const displayedParameters = this.#convertObjectToParameterSchema(
'', parameters, {
typeRef: DUMMY_DATA,
type: ParameterType.OBJECT,
name: '',
description: '',
optional: true,
value: [],
},
schema.parameters)
.value as Parameter[];
const valueByName = new Map(this.parameters.map(param => [param.name, param]));
for (const param of displayedParameters) {
const existingParam = valueByName.get(param.name);
if (existingParam) {
existingParam.value = param.value;
}
}
this.requestUpdate();
}
#convertObjectToParameterSchema(key: string, value: unknown, schema?: Parameter, initialSchema?: Parameter[]):
Parameter {
const type = schema?.type || typeof value;
const description = schema?.description ?? '';
const optional = schema?.optional ?? true;
switch (type) {
case ParameterType.STRING:
case ParameterType.BOOLEAN:
case ParameterType.NUMBER:
return this.#convertPrimitiveParameter(key, value, schema);
case ParameterType.OBJECT:
return this.#convertObjectParameter(key, value, schema, initialSchema);
case ParameterType.ARRAY:
return this.#convertArrayParameter(key, value, schema);
}
return {
type,
name: key,
optional,
typeRef: schema?.typeRef,
value,
description,
} as Parameter;
}
#convertPrimitiveParameter(key: string, value: unknown, schema?: Parameter): Parameter {
const type = schema?.type || typeof value;
const description = schema?.description ?? '';
const optional = schema?.optional ?? true;
return {
type,
name: key,
optional,
typeRef: schema?.typeRef,
value,
description,
isCorrectType: schema ? this.#isValueOfCorrectType(schema, String(value)) : true,
} as Parameter;
}
#convertObjectParameter(key: string, value: unknown, schema?: Parameter, initialSchema?: Parameter[]): Parameter {
const description = schema?.description ?? '';
if (typeof value !== 'object' || value === null) {
throw Error('The value is not an object');
}
const typeRef = schema?.typeRef;
if (!typeRef) {
throw Error('Every object parameters should have a type ref');
}
const nestedType = typeRef === DUMMY_DATA ? initialSchema : this.typesByName.get(typeRef);
if (!nestedType) {
throw Error('No nested type for keys were found');
}
const objectValues = [];
for (const objectKey of Object.keys(value)) {
const objectType = nestedType.find(param => param.name === objectKey);
objectValues.push(
this.#convertObjectToParameterSchema(objectKey, (value as Record<string, unknown>)[objectKey], objectType));
}
return {
type: ParameterType.OBJECT,
name: key,
optional: schema.optional,
typeRef: schema.typeRef,
value: objectValues,
description,
isCorrectType: true,
};
}
#convertArrayParameter(key: string, value: unknown, schema?: Parameter): Parameter {
const description = schema?.description ?? '';
const optional = schema?.optional ?? true;
const typeRef = schema?.typeRef;
if (!typeRef) {
throw Error('Every array parameters should have a type ref');
}
if (!Array.isArray(value)) {
throw Error('The value is not an array');
}
const nestedType = this.#isTypePrimitive(typeRef) ? undefined : {
optional: true,
type: ParameterType.OBJECT as ParameterType.OBJECT,
value: [],
typeRef,
description: '',
name: '',
};
const objectValues = [];
for (let i = 0; i < value.length; i++) {
const temp = this.#convertObjectToParameterSchema(`${i}`, value[i], nestedType);
objectValues.push(temp);
}
return {
type: ParameterType.ARRAY,
name: key,
optional,
typeRef: schema?.typeRef,
value: objectValues,
description,
isCorrectType: true,
};
}
#handlePopoverDescriptions(event: MouseEvent):
{box: AnchorBox, show: (popover: UI.GlassPane.GlassPane) => Promise<boolean>}|null {
const hintElement = event.composedPath()[0] as HTMLElement;
const elementData = this.#getDescriptionAndTypeForElement(hintElement);
if (!elementData?.description) {
return null;
}
const [head, tail] = splitDescription(elementData.description);
const type = elementData.type;
const replyArgs = elementData.replyArgs;
let popupContent = '';
// replyArgs and type cannot get into conflict because replyArgs is attached to a command and type to a parameter
if (replyArgs) {
popupContent = tail + `Returns: ${replyArgs}<br>`;
} else if (type) {
popupContent = tail + `<br>Type: ${type}<br>`;
} else {
popupContent = tail;
}
return {
box: hintElement.boxInWindow(),
show: async (popover: UI.GlassPane.GlassPane) => {
const popupElement = new ElementsComponents.CSSHintDetailsView.CSSHintDetailsView({
getMessage: () => `<code><span>${head}</span></code>`,
getPossibleFixMessage: () => popupContent,
getLearnMoreLink: () =>
`https://chromedevtools.github.io/devtools-protocol/tot/${this.command.split('.')[0]}/`,
});
popover.contentElement.appendChild(popupElement);
return true;
},
};
}
#getDescriptionAndTypeForElement(hintElement: HTMLElement):
{description: string, type?: ParameterType, replyArgs?: string[]}|undefined {
if (hintElement.matches('.command')) {
const metadata = this.metadataByCommand.get(this.command);
if (metadata) {
return {description: metadata.description, replyArgs: metadata.replyArgs};
}
}
if (hintElement.matches('.parameter')) {
const id = hintElement.dataset.paramid;
if (!id) {
return;
}
const pathArray = id.split('.');
const {parameter} = this.#getChildByPath(pathArray);
if (!parameter.description) {
return;
}
return {description: parameter.description, type: parameter.type};
}
return;
}
getCommandJson(): string {
return this.command !== '' ? JSON.stringify({command: this.command, parameters: this.getParameters()}) : '';
}
#copyToClipboard(): void {
const commandJson = this.getCommandJson();
Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(commandJson);
}
#handleCommandSend(): void {
this.dispatchEventToListeners(Events.SUBMIT_EDITOR, {
command: this.command,
parameters: this.getParameters(),
targetId: this.targetId,
});
}
populateParametersForCommandWithDefaultValues(): void {
const commandParameters = this.metadataByCommand.get(this.command)?.parameters;
if (!commandParameters) {
return;
}
this.parameters = commandParameters.map((parameter: Parameter) => {
return this.#populateParameterDefaults(parameter);
});
}
#populateParameterDefaults(parameter: Parameter): Parameter {
if (parameter.type === ParameterType.OBJECT) {
let typeRef = parameter.typeRef;
if (!typeRef) {
typeRef = DUMMY_DATA;
}
// Fallback to empty array is extremely rare.
// It happens when the keys for an object are not registered like for Tracing.MemoryDumpConfig or headers for instance.
const nestedTypes = this.typesByName.get(typeRef) ?? [];
const nestedParameters = nestedTypes.map(nestedType => {
return this.#populateParameterDefaults(nestedType);
});
return {
...parameter,
value: parameter.optional ? undefined : nestedParameters,
isCorrectType: true,
} as Parameter;
}
if (parameter.type === ParameterType.ARRAY) {
return {
...parameter,
value: parameter?.optional ? undefined :
parameter.value?.map(param => this.#populateParameterDefaults(param)) || [],
isCorrectType: true,
};
}
return {
...parameter,
value: parameter.optional ? undefined : defaultValueByType.get(parameter.type),
isCorrectType: true,
} as Parameter;
}
#getChildByPath(pathArray: string[]): {parameter: Parameter, parentParameter: Parameter} {
let parameters = this.parameters;
let parentParameter;
for (let i = 0; i < pathArray.length; i++) {
const name = pathArray[i];
const parameter = parameters.find(param => param.name === name);
if (i === pathArray.length - 1) {
return {parameter, parentParameter} as {parameter: Parameter, parentParameter: Parameter};
}
if (parameter?.type === ParameterType.ARRAY || parameter?.type === ParameterType.OBJECT) {
if (parameter.value) {
parameters = parameter.value;
}
} else {
throw new Error('Parameter on the path in not an object or an array');
}
parentParameter = parameter;
}
throw new Error('Not found');
}
#isValueOfCorrectType(parameter: Parameter, value: string): boolean {
if (parameter.type === ParameterType.NUMBER && isNaN(Number(value))) {
return false;
}
// For boolean or array parameters, this will create an array of the values the user can enter
const acceptedValues = this.#computeDropdownValues(parameter);
// Check to see if the entered value by the user is indeed part of the values accepted by the enum or boolean parameter
if (acceptedValues.length !== 0 && !acceptedValues.includes(value)) {
return false;
}
return true;
}
#saveParameterValue = (event: Event): void => {
if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
return;
}
let value: string;
if (event instanceof KeyboardEvent) {
const editableContent = event.target.renderRoot.querySelector('devtools-editable-content');
if (!editableContent) {
return;
}
value = editableContent.innerText;
} else {
value = event.target.value;
}
const paramId = event.target.getAttribute('data-paramid');
if (!paramId) {
return;
}
const pathArray = paramId.split('.');
const object = this.#getChildByPath(pathArray).parameter;
if (value === '') {
object.value = defaultValueByType.get(object.type);
} else {
object.value = value;
object.isCorrectType = this.#isValueOfCorrectType(object, value);
}
// Needed to render the delete button for object parameters
this.requestUpdate();
};
#saveNestedObjectParameterKey = (event: Event): void => {
if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
return;
}
const value = event.target.value;
const paramId = event.target.getAttribute('data-paramid');
if (!paramId) {
return;
}
const pathArray = paramId.split('.');
const {parameter} = this.#getChildByPath(pathArray);
parameter.name = value;
// Needed to render the delete button for object parameters
this.requestUpdate();
};
#handleParameterInputKeydown = (event: KeyboardEvent): void => {
if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
return;
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
this.#saveParameterValue(event);
}
};
#handleFocusParameter(event: Event): void {
if (!(event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput)) {
return;
}
const paramId = event.target.getAttribute('data-paramid');
if (!paramId) {
return;
}
const pathArray = paramId.split('.');
const object = this.#getChildByPath(pathArray).parameter;
object.isCorrectType = true;
this.requestUpdate();
}
#handleCommandInputBlur = async(event: Event): Promise<void> => {
if (event.target instanceof SuggestionInput.SuggestionInput.SuggestionInput) {
this.command = event.target.value;
}
this.populateParametersForCommandWithDefaultValues();
};
#computeTargetLabel(target: SDK.Target.Target): string|undefined {
if (!target) {
return undefined;
}
return `${target.name()} (${target.inspectedURL()})`;
}
#isTypePrimitive(type: string): boolean {
if (type === ParameterType.STRING || type === ParameterType.BOOLEAN || type === ParameterType.NUMBER) {
return true;
}
return false;
}
#createNestedParameter(type: Parameter, name: string): Parameter {
if (type.type === ParameterType.OBJECT) {
let typeRef = type.typeRef;
if (!typeRef) {
typeRef = DUMMY_DATA;
}
const nestedTypes = this.typesByName.get(typeRef) ?? [];
const nestedValue: Parameter[] =
nestedTypes.map(nestedType => this.#createNestedParameter(nestedType, nestedType.name));
return {
type: ParameterType.OBJECT,
name,
optional: type.optional,
typeRef,
value: nestedValue,
isCorrectType: true,
description: type.description,
};
}
return {
type: type.type,
name,
optional: type.optional,
isCorrectType: true,
typeRef: type.typeRef,
value: type.optional ? undefined : defaultValueByType.get(type.type),
description: type.description,
} as Parameter;
}
#handleAddParameter(parameterId: string): void {
const pathArray = parameterId.split('.');
const {parameter, parentParameter} = this.#getChildByPath(pathArray);
if (!parameter) {
return;
}
switch (parameter.type) {
case ParameterType.ARRAY: {
const typeRef = parameter.typeRef;
if (!typeRef) {
throw Error('Every array parameter must have a typeRef');
}
const nestedType = this.typesByName.get(typeRef) ?? [];
const nestedValue: Parameter[] = nestedType.map(type => this.#createNestedParameter(type, type.name));
let type = this.#isTypePrimitive(typeRef) ? typeRef : ParameterType.OBJECT;
// If the typeRef is actually a ref to an enum type, the type of the nested param should be a string
if (nestedType.length === 0) {
if (this.enumsByName.get(typeRef)) {
type = ParameterType.STRING;
}
}
// In case the parameter is an optional array, its value will be undefined so before pushing new value inside,
// we reset it to empty array
if (!parameter.value) {
parameter.value = [];
}
parameter.value.push({
type,
name: String(parameter.value.length),
optional: true,
typeRef,
value: nestedValue.length !== 0 ? nestedValue : '',
description: '',
isCorrectType: true,
} as Parameter);
break;
}
case ParameterType.OBJECT: {
let typeRef = parameter.typeRef;
if (!typeRef) {
typeRef = DUMMY_DATA;
}
if (!parameter.value) {
parameter.value = [];
}
if (!this.typesByName.get(typeRef)) {
parameter.value.push({
type: ParameterType.STRING,
name: '',
optional: true,
value: '',
isCorrectType: true,
description: '',
isKeyEditable: true,
});
break;
}
const nestedTypes = this.typesByName.get(typeRef) ?? [];
const nestedValue: Parameter[] =
nestedTypes.map(nestedType => this.#createNestedParameter(nestedType, nestedType.name));
const nestedParameters = nestedTypes.map(nestedType => {
return this.#populateParameterDefaults(nestedType);
});
if (parentParameter) {
parameter.value.push({
type: ParameterType.OBJECT,
name: '',
optional: true,
typeRef,
value: nestedValue,
isCorrectType: true,
description: '',
});
} else {
parameter.value = nestedParameters;
}
break;
}
default:
// For non-array and non-object parameters, set the value to the default value if available.
parameter.value = defaultValueByType.get(parameter.type);
break;
}
this.requestUpdate();
}
#handleClearParameter(parameter: Parameter, isParentArray?: boolean): void {
if (!parameter || parameter.value === undefined) {
return;
}
switch (parameter.type) {
case ParameterType.OBJECT:
if (parameter.optional && !isParentArray) {
parameter.value = undefined;
break;
}
if (!parameter.typeRef || !this.typesByName.get(parameter.typeRef)) {
parameter.value = [];
} else {
parameter.value.forEach(param => this.#handleClearParameter(param, isParentArray));
}
break;
case ParameterType.ARRAY:
parameter.value = parameter.optional ? undefined : [];
break;
default:
parameter.value = parameter.optional ? undefined : defaultValueByType.get(parameter.type);
parameter.isCorrectType = true;
break;
}
this.requestUpdate();
}
#handleDeleteParameter(parameter: Parameter, parentParameter: Parameter): void {
if (!parameter) {
return;
}
if (!Array.isArray(parentParameter.value)) {
return;
}
parentParameter.value.splice(parentParameter.value.findIndex(p => p === parameter), 1);
if (parentParameter.type === ParameterType.ARRAY) {
for (let i = 0; i < parentParameter.value.length; i++) {
parentParameter.value[i].name = String(i);
}
}
this.requestUpdate();
}
#renderTargetSelectorRow(): Lit.TemplateResult|undefined {
const target = this.targets.find(el => el.id() === this.targetId);
const targetLabel = target ? this.#computeTargetLabel(target) : this.#computeTargetLabel(this.targets[0]);
// clang-format off
return html`
<div class="row attribute padded">
<div>target<span class="separator">:</span></div>
<devtools-select-menu
class="target-select-menu"
@selectmenuselected=${this.#onTargetSelected}
.showDivider=${true}
.showArrow=${true}
.sideButton=${false}
.showSelectedItem=${true}
.position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM}
.buttonTitle=${targetLabel || ''}
jslog=${VisualLogging.dropDown('targets').track({click: true})}
>
${repeat(this.targets, target => {
return html`
<devtools-menu-item
class="no-checkmark"
.value=${target.id()}>
${this.#computeTargetLabel(target)}
</devtools-menu-item>
`;
},
)}
</devtools-select-menu>
</div>
`;
// clang-format on
}
#onTargetSelected(event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void {
this.targetId = event.itemValue as string;
this.requestUpdate();
}
#computeDropdownValues(parameter: Parameter): string[] {
// The suggestion box should only be shown for parameters of type string and boolean
if (parameter.type === ParameterType.STRING) {
const enums = this.enumsByName.get(`${parameter.typeRef}`) ?? {};
return Object.values(enums);
}
if (parameter.type === ParameterType.BOOLEAN) {
return ['true', 'false'];
}
return [];
}
#renderInlineButton(opts: {
title: string,
iconName: string,
classMap: {[name: string]: string|boolean|number},
onClick: (event: MouseEvent) => void,
jslogContext: string,
}): Lit.TemplateResult|undefined {
return html`
<devtools-button
title=${opts.title}
.size=${Buttons.Button.Size.SMALL}
.iconName=${opts.iconName}
.variant=${Buttons.Button.Variant.ICON}
class=${classMap(opts.classMap)}
@click=${opts.onClick}
.jslogContext=${opts.jslogContext}
></devtools-button>
`;
}
#renderWarningIcon(): Lit.TemplateResult|undefined {
return html`<devtools-icon
.data=${{
iconName:
'warning-filled', color: 'var(--icon-warning)', width: '14px', height: '14px',
}
}
class=${classMap({
'warning-icon': true,
})}
>
</devtools-icon>`;
}
/**
* Renders the parameters list corresponding to a specific CDP command.
*/
#renderParameters(parameters: Parameter[], id?: string, parentParameter?: Parameter, parentParameterId?: string):
Lit.TemplateResult|undefined {
parameters.sort((a, b) => Number(a.optional) - Number(b.optional));
// clang-format off
return html`
<ul>
${repeat(parameters, parameter => {
const parameterId = parentParameter ? `${parentParameterId}` + '.' + `${parameter.name}` : parameter.name;
const subparameters: Parameter[] = parameter.type === ParameterType.ARRAY || parameter.type === ParameterType.OBJECT ? (parameter.value ?? []) : [];
const handleInputOnBlur = (event: Event): void => {
this.#saveParameterValue(event);
};
const handleKeydown = (event: KeyboardEvent): void => {
this.#handleParameterInputKeydown(event);
};
const handleFocus = (event: Event): void => {
this.#handleFocusParameter(event);
};
const handleParamKeyOnBlur = (event: Event): void => {
this.#saveNestedObjectParameterKey(event);
};
const isPrimitive = this.#isTypePrimitive(parameter.type);
const isArray = parameter.type === ParameterType.ARRAY;
const isParentArray = parentParameter && parentParameter.type === ParameterType.ARRAY;
const isParentObject = parentParameter && parentParameter.type === ParameterType.OBJECT;
const isObject = parameter.type === ParameterType.OBJECT;
const isParamValueUndefined = parameter.value === undefined;
const isParamOptional = parameter.optional;
const hasTypeRef = isObject && parameter.typeRef && this.typesByName.get(parameter.typeRef) !== undefined;
// This variable indicates that this parameter is a parameter nested inside an object parameter
// that no keys defined inside the CDP documentation.
const hasNoKeys = parameter.isKeyEditable;
const isCustomEditorDisplayed = isObject && !hasTypeRef;
const hasOptions = parameter.type === ParameterType.STRING || parameter.type === ParameterType.BOOLEAN;
const canClearParameter = (isArray && !isParamValueUndefined && parameter.value?.length !== 0) || (isObject && !isParamValueUndefined);
const parametersClasses = {
'optional-parameter': parameter.optional,
parameter: true,
'undefined-parameter': parameter.value === undefined && parameter.optional,
};
const inputClasses = {
'json-input': true,
};
return html`
<li class="row">
<div class="row-icons">
${!parameter.isCorrectType ? html`${this.#renderWarningIcon()}` : nothing}
<!-- If an object parameter has no predefined keys, show an input to enter the key, otherwise show the name of the parameter -->
<div class=${classMap(parametersClasses)} data-paramId=${parameterId}>
${hasNoKeys ?
html`<devtools-suggestion-input
data-paramId=${parameterId}
isKey=${true}
.isCorrectInput=${live(parameter.isCorrectType)}
.options=${hasOptions ? this.#computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.name ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
@blur=${handleParamKeyOnBlur}
@focus=${handleFocus}
@keydown=${handleKeydown}
></devtools-suggestion-input>`:
html`${parameter.name}`} <span class="separator">:</span>
</div>
<!-- Render button to add values inside an array parameter -->
${isArray ? html`
${this.#renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => this.#handleAddParameter(parameterId),
classMap: { 'add-button': true },
jslogContext: 'protocol-monitor.add-parameter',
})}
`: nothing}
<!-- Render button to complete reset an array parameter or an object parameter-->
${canClearParameter ?
this.#renderInlineButton({
title: i18nString(UIStrings.resetDefaultValue),
iconName: 'clear',
onClick: () => this.#handleClearParameter(parameter, isParentArray),
classMap: {'clear-button': true},
jslogContext: 'protocol-monitor.reset-to-default-value',
}) : nothing}
<!-- Render the buttons to change the value from undefined to empty string for optional primitive parameters -->
${isPrimitive && !isParentArray && isParamOptional && isParamValueUndefined ?
html` ${this.#renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => this.#handleAddParameter(parameterId),
classMap: { 'add-button': true },
jslogContext: 'protocol-monitor.add-parameter',
})}` : nothing}
<!-- Render the buttons to change the value from undefined to populate the values inside object with their default values -->
${isObject && isParamOptional && isParamValueUndefined && hasTypeRef ?
html` ${this.#renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => this.#handleAddParameter(parameterId),
classMap: { 'add-button': true },
jslogContext: 'protocol-monitor.add-parameter',
})}` : nothing}
</div>
<div class="row-icons">
<!-- If an object has no predefined keys, show an input to enter the value, and a delete icon to delete the whole key/value pair -->
${hasNoKeys && isParentObject ? html`
<!-- @ts-ignore -->
<devtools-suggestion-input
data-paramId=${parameterId}
.isCorrectInput=${live(parameter.isCorrectType)}
.options=${hasOptions ? this.#computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter-value'}
@blur=${handleInputOnBlur}
@focus=${handleFocus}
@keydown=${handleKeydown}
></devtools-suggestion-input>
${this.#renderInlineButton({
title: i18nString(UIStrings.deleteParameter),
iconName: 'bin',
onClick: () => this.#handleDeleteParameter(parameter, parentParameter),
classMap: { deleteButton: true, deleteIcon: true },
jslogContext: 'protocol-monitor.delete-parameter',
})}`: nothing}
<!-- In case the parameter is not optional or its value is not undefined render the input -->
${isPrimitive && !hasNoKeys && (!isParamValueUndefined || !isParamOptional) && (!isParentArray) ?
html`
<!-- @ts-ignore -->
<devtools-suggestion-input
data-paramId=${parameterId}
.strikethrough=${live(parameter.isCorrectType)}
.options=${hasOptions ? this.#computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter-value'}
@blur=${handleInputOnBlur}
@focus=${handleFocus}
@keydown=${handleKeydown}
></devtools-suggestion-input>` : nothing}
<!-- Render the buttons to change the value from empty string to undefined for optional primitive parameters -->
${isPrimitive &&!hasNoKeys && !isParentArray && isParamOptional && !isParamValueUndefined ?
html` ${this.#renderInlineButton({
title: i18nString(UIStrings.resetDefaultValue),
iconName: 'clear',
onClick: () => this.#handleClearParameter(parameter),
classMap: { 'clear-button': true },
jslogContext: 'protocol-monitor.reset-to-default-value',
})}` : nothing}
<!-- If the parameter is an object with no predefined keys, renders a button to add key/value pairs to it's value -->
${isCustomEditorDisplayed ? html`
${this.#renderInlineButton({
title: i18nString(UIStrings.addCustomProperty),
iconName: 'plus',
onClick: () => this.#handleAddParameter(parameterId),
classMap: { 'add-button': true },
jslogContext: 'protocol-monitor.add-custom-property',
})}
` : nothing}
<!-- In case the parameter is nested inside an array we render the input field as well as a delete button -->
${isParentArray ? html`
<!-- If the parameter is an object we don't want to display the input field we just want the delete button-->
${!isObject ? html`
<!-- @ts-ignore -->
<devtools-suggestion-input
data-paramId=${parameterId}
.options=${hasOptions ? this.#computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter'}
@blur=${handleInputOnBlur}
@keydown=${handleKeydown}
class=${classMap(inputClasses)}
></devtools-suggestion-input>` : nothing}
${this.#renderInlineButton({
title: i18nString(UIStrings.deleteParameter),
iconName: 'bin',
onClick: () => this.#handleDeleteParameter(parameter, parentParameter),
classMap: { 'delete-button': true },
jslogContext: 'protocol-monitor.delete-parameter',
})}` : nothing}
</div>
</li>
${this.#renderParameters(subparameters, id, parameter, parameterId)}
`;
})}
</ul>
`;
// clang-format on
}
override performUpdate(): void {
// clang-format off
render(html`
<div class="wrapper">
${this.#renderTargetSelectorRow()}
<div class="row attribute padded">
<div class="command">command<span class="separator">:</span></div>
<devtools-suggestion-input
.options=${[...this.metadataByCommand.keys()]}
.value=${this.command}
.placeholder=${'Enter your command...'}
.suggestionFilter=${suggestionFilter}
.jslogContext=${'command'}
@blur=${this.#handleCommandInputBlur}
class=${classMap({'json-input': true})}
></devtools-suggestion-input>
</div>
${this.parameters.length ? html`
<div class="row attribute padded">
<div>parameters<span class="separator">:</span></div>
</div>
${this.#renderParameters(this.parameters)}
` : nothing}
</div>
<devtools-toolbar class="protocol-monitor-sidebar-toolbar">
<devtools-button title=${i18nString(UIStrings.copyCommand)}
.iconName=${'copy'}
.jslogContext=${'protocol-monitor.copy-command'}
.variant=${Buttons.Button.Variant.TOOLBAR}
@click=${this.#copyToClipboard}></devtools-button>
<div class=toolbar-spacer></div>
<devtools-button title=${Host.Platform.isMac() ? i18nString(UIStrings.sendCommandCmdEnter) : i18nString(UIStrings.sendCommandCtrlEnter)}
.iconName=${'send'}
jslogContext=${'protocol-monitor.send-command'}
.variant=${Buttons.Button.Variant.PRIMARY_TOOLBAR}
@click=${this.#handleCommandSend}></devtools-button>
</devtools-toolbar>`, this.contentElement, {host: this});
// clang-format on
}
}