chrome-devtools-frontend
Version:
Chrome DevTools UI
1,251 lines (1,133 loc) • 46.6 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 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 button that sends a CDP command.
*/
sendCommandCtrlEnter: 'Send command - Ctrl+Enter',
/**
* @description The title of a button that sends a CDP command.
*/
sendCommandCmdEnter: 'Send command - ⌘+Enter',
/**
* @description The title of a button that copies a CDP command.
*/
copyCommand: 'Copy command',
/**
* @description A label for a select input that allows selecting a CDP target to send the commands to.
*/
selectTarget: 'Select a target',
} as const;
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: Record<string, unknown>;
targetId?: string;
}
interface ViewInput {
onKeydown: (event: KeyboardEvent) => void;
metadataByCommand: Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>;
command: string;
parameters: Parameter[];
typesByName: Map<string, Parameter[]>;
onCommandInputBlur: (event: Event) => void;
onCommandSend: () => void;
onCopyToClipboard: () => void;
targets: SDK.Target.Target[];
targetId: string|undefined;
onAddParameter: (parameterId: string) => void;
onClearParameter: (parameter: Parameter, isParentArray?: boolean) => void;
onDeleteParameter: (parameter: Parameter, parentParameter: Parameter) => void;
onTargetSelected: (event: Event) => void;
computeDropdownValues: (parameter: Parameter) => string[];
onParameterFocus: (event: Event) => void;
onParameterKeydown: (event: KeyboardEvent) => void;
onParameterKeyBlur: (event: Event) => void;
onParameterValueBlur: (event: Event) => void;
}
export type View = (input: ViewInput, output: object, targer: HTMLElement) => void;
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 = new Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>();
#typesByName = new Map<string, Parameter[]>();
#enumsByName = new Map<string, Record<string, string>>();
#parameters: Parameter[] = [];
#targets: SDK.Target.Target[] = [];
#command = '';
#targetId?: string;
#hintPopoverHelper?: UI.PopoverHelper.PopoverHelper;
#view: View;
constructor(element: HTMLElement, view = DEFAULT_VIEW) {
super(/* useShadowDom=*/ true, undefined, element);
this.#view = view;
this.registerRequiredCSS(editorWidgetStyles);
}
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);
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(): Record<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: Record<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: Record<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 Record<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 new Error('The value is not an object');
}
const typeRef = schema?.typeRef;
if (!typeRef) {
throw new Error('Every object parameters should have a type ref');
}
const nestedType = typeRef === DUMMY_DATA ? initialSchema : this.typesByName.get(typeRef);
if (!nestedType) {
throw new 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 new Error('Every array parameters should have a type ref');
}
if (!Array.isArray(value)) {
throw new Error('The value is not an array');
}
const nestedType = 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|KeyboardEvent):
{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 && replyArgs.length > 0) {
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: () => `<span>${head}</span>`,
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();
};
#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 new 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 = 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?.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();
}
#onTargetSelected(event: Event): void {
if (event.target instanceof HTMLSelectElement) {
this.targetId = event.target.value;
}
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 [];
}
override performUpdate(): void {
const viewInput = {
onParameterValueBlur: (event: Event): void => {
this.#saveParameterValue(event);
},
onParameterKeydown: (event: KeyboardEvent): void => {
this.#handleParameterInputKeydown(event);
},
onParameterFocus: (event: Event): void => {
this.#handleFocusParameter(event);
},
onParameterKeyBlur: (event: Event): void => {
this.#saveNestedObjectParameterKey(event);
},
onKeydown: (event: KeyboardEvent): void => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
this.#handleParameterInputKeydown(event);
this.#handleCommandSend();
}
},
parameters: this.parameters,
metadataByCommand: this.metadataByCommand,
command: this.command,
typesByName: this.typesByName,
onCommandInputBlur: (event: Event) => this.#handleCommandInputBlur(event),
onCommandSend: () => this.#handleCommandSend(),
onCopyToClipboard: () => this.#copyToClipboard(),
targets: this.targets,
targetId: this.targetId,
onAddParameter: (parameterId: string) => {
this.#handleAddParameter(parameterId);
},
onClearParameter: (parameter: Parameter, isParentArray?: boolean) => {
this.#handleClearParameter(parameter, isParentArray);
},
onDeleteParameter: (parameter: Parameter, parentParameter: Parameter) => {
this.#handleDeleteParameter(parameter, parentParameter);
},
onTargetSelected: (event: Event) => {
this.#onTargetSelected(event);
},
computeDropdownValues: (parameter: Parameter) => {
return this.#computeDropdownValues(parameter);
},
};
const viewOutput = {};
this.#view(viewInput, viewOutput, this.contentElement);
}
}
function isTypePrimitive(type: string): boolean {
if (type === ParameterType.STRING || type === ParameterType.BOOLEAN || type === ParameterType.NUMBER) {
return true;
}
return false;
}
function renderTargetSelectorRow(input: ViewInput): Lit.TemplateResult|undefined {
// clang-format off
return html`
<div class="row attribute padded">
<div>target<span class="separator">:</span></div>
<select class="target-selector"
title=${i18nString(UIStrings.selectTarget)}
jslog=${VisualLogging.dropDown('target-selector').track({change: true})}
@change=${input.onTargetSelected}>
${input.targets.map(target => html`
<option jslog=${VisualLogging.item('target').track({click: true})}
value=${target.id()} ?selected=${target.id() === input.targetId}>
${target.name()} (${target.inspectedURL()})
</option>`)}
</select>
</div>
`;
// clang-format on
}
function renderInlineButton(opts: {
title: string,
iconName: string,
classMap: Record<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>
`;
}
function 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.
*/
function renderParameters(
input: ViewInput, 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 isPrimitive = 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 && input.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`${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 ? input.computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.name ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
@blur=${input.onParameterKeyBlur}
@focus=${input.onParameterFocus}
@keydown=${input.onParameterKeydown}
></devtools-suggestion-input>`:
html`${parameter.name}`} <span class="separator">:</span>
</div>
<!-- Render button to add values inside an array parameter -->
${isArray ? html`
${renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => input.onAddParameter(parameterId),
classMap: { 'add-button': true },
jslogContext: 'protocol-monitor.add-parameter',
})}
`: nothing}
<!-- Render button to complete reset an array parameter or an object parameter-->
${canClearParameter ?
renderInlineButton({
title: i18nString(UIStrings.resetDefaultValue),
iconName: 'clear',
onClick: () => input.onClearParameter(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` ${renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => input.onAddParameter(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` ${renderInlineButton({
title: i18nString(UIStrings.addParameter),
iconName: 'plus',
onClick: () => input.onAddParameter(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 ? input.computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter-value'}
@blur=${input.onParameterValueBlur}
@focus=${input.onParameterFocus}
@keydown=${input.onParameterKeydown}
></devtools-suggestion-input>
${renderInlineButton({
title: i18nString(UIStrings.deleteParameter),
iconName: 'bin',
onClick: () => input.onDeleteParameter(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 ? input.computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter-value'}
@blur=${input.onParameterValueBlur}
@focus=${input.onParameterFocus}
@keydown=${input.onParameterKeydown}
></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` ${renderInlineButton({
title: i18nString(UIStrings.resetDefaultValue),
iconName: 'clear',
onClick: () => input.onClearParameter(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`
${renderInlineButton({
title: i18nString(UIStrings.addCustomProperty),
iconName: 'plus',
onClick: () => input.onAddParameter(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 ? input.computeDropdownValues(parameter) : []}
.autocomplete=${false}
.value=${live(parameter.value ?? '')}
.placeholder=${parameter.value === '' ? EMPTY_STRING : `<${defaultValueByType.get(parameter.type)}>`}
.jslogContext=${'parameter'}
@blur=${input.onParameterValueBlur}
@keydown=${input.onParameterKeydown}
class=${classMap(inputClasses)}
></devtools-suggestion-input>` : nothing}
${renderInlineButton({
title: i18nString(UIStrings.deleteParameter),
iconName: 'bin',
onClick: () => input.onDeleteParameter(parameter, parentParameter),
classMap: { 'delete-button': true },
jslogContext: 'protocol-monitor.delete-parameter',
})}` : nothing}
</div>
</li>
${renderParameters(input, subparameters, id, parameter, parameterId)}
`;
})}
</ul>
`;
// clang-format on
}
export const DEFAULT_VIEW: View = (input, _output, target) => {
// clang-format off
render(html`
<div class="wrapper" jslog=${VisualLogging.pane('command-editor').track({resize: true})}>
<div class="editor-wrapper" @keydown=${input.onKeydown}>
${renderTargetSelectorRow(input)}
<div class="row attribute padded">
<div class="command">command<span class="separator">:</span></div>
<devtools-suggestion-input
.options=${[...input.metadataByCommand.keys()]}
.value=${input.command}
.placeholder=${'Enter your command…'}
.suggestionFilter=${suggestionFilter}
.jslogContext=${'command'}
@blur=${input.onCommandInputBlur}
class=${classMap({'json-input': true})}
></devtools-suggestion-input>
</div>
${input.parameters.length ? html`
<div class="row attribute padded">
<div>parameters<span class="separator">:</span></div>
</div>
${renderParameters(input, input.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=${input.onCopyToClipboard}></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=${input.onCommandSend}></devtools-button>
</devtools-toolbar>
</div>`, target, {host: input});
// clang-format on
};