chrome-devtools-frontend
Version:
Chrome DevTools UI
687 lines (635 loc) • 27.6 kB
text/typescript
// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../../ui/kit/kit.js';
import '../../ui/components/lists/lists.js';
import * as Common from '../../core/common/common.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 type * as Lists from '../../ui/components/lists/lists.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import locationsSettingsTabStyles from './locationsSettingsTab.css.js';
const {createRef, ref} = Directives;
export {locationsSettingsTabStyles};
const UIStrings = {
/**
* @description Title in the Locations settings tab, where custom geographic locations that the user
* has entered are stored.
*/
locations: 'Locations',
/**
* @description Label for the name of a geographic location that the user has entered.
*/
locationName: 'Location name',
/**
* @description Abbreviation of latitude in the Locations settings tab of the Device toolbar.
*/
lat: 'Lat',
/**
* @description Abbreviation of longitude in the Locations settings tab of the Device toolbar.
*/
long: 'Long',
/**
* @description Text in the Sensors view of the Device toolbar.
*/
timezoneId: 'Timezone ID',
/**
* @description Label for text input for the locale of a particular location.
*/
locale: 'Locale',
/**
* @description Label for text input for the latitude of a GPS position.
*/
latitude: 'Latitude',
/**
* @description Label for text input for the longitude of a GPS position.
*/
longitude: 'Longitude',
/**
* @description Label for text input for the accuracy of a GPS position.
*/
accuracy: 'Accuracy',
/**
* @description Error message in the Locations settings tab that declares the location name input must not be empty.
*/
locationNameCannotBeEmpty: 'Location name can’t be empty',
/**
* @description Error message in the Locations settings tab that declares the maximum length of the location name.
* @example {50} PH1
*/
locationNameMustBeLessThanS: 'Location name must be less than {PH1} characters',
/**
* @description Error message in the Locations settings tab that declares that the value for the latitude input must be a number.
*/
latitudeMustBeANumber: 'Latitude must be a number',
/**
* @description Error message in the Locations settings tab that declares the minimum value for the latitude input.
* @example {-90} PH1
*/
latitudeMustBeGreaterThanOrEqual: 'Latitude must be greater than or equal to {PH1}',
/**
* @description Error message in the Locations settings tab that declares the maximum value for the latitude input.
* @example {90} PH1
*/
latitudeMustBeLessThanOrEqualToS: 'Latitude must be less than or equal to {PH1}',
/**
* @description Error message in the Locations settings tab that declares that the value for the longitude input must be a number.
*/
longitudeMustBeANumber: 'Longitude must be a number',
/**
* @description Error message in the Locations settings tab that declares the minimum value for the longitude input.
* @example {-180} PH1
*/
longitudeMustBeGreaterThanOr: 'Longitude must be greater than or equal to {PH1}',
/**
* @description Error message in the Locations settings tab that declares the maximum value for the longitude input.
* @example {180} PH1
*/
longitudeMustBeLessThanOrEqualTo: 'Longitude must be less than or equal to {PH1}',
/**
* @description Error message in the Locations settings tab that declares timezone ID input invalid.
*/
timezoneIdMustContainAlphabetic: 'Timezone ID must contain alphabetic characters',
/**
* @description Error message in the Locations settings tab that declares locale input invalid.
*/
localeMustContainAlphabetic: 'Locale must contain alphabetic characters',
/**
* @description Error message in the Locations settings tab that declares that the value for the accuracy input must be a number.
*/
accuracyMustBeANumber: 'Accuracy must be a number',
/**
* @description Error message in the Locations settings tab that declares the minimum value for the accuracy input.
* @example {0} PH1
*/
accuracyMustBeGreaterThanOrEqual: 'Accuracy must be greater than or equal to {PH1}',
/**
* @description Text of add locations button in the Locations settings tab of the Device toolbar.
*/
addLocation: 'Add location',
/**
* @description Title for the dialog when editing an existing location in the Locations settings tab.
*/
editLocation: 'Edit location',
/**
* @description Label for button to save location changes.
*/
save: 'Save',
/**
* @description Label for button to cancel location edits.
*/
cancel: 'Cancel',
/**
* @description Label for button to close the location dialog.
*/
close: 'Close',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/sensors/LocationsSettingsTab.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
function renderItemView(location: LocationDescription): LitTemplate {
// clang-format off
return html`
<div class="locations-list-item" role="row">
<div class="locations-list-text locations-list-title" role="cell">
<div class="locations-list-title-text" title=${location.title}>${location.title}</div>
</div>
<div class="locations-list-separator"></div>
<div class="locations-list-text" role="cell">${location.lat}</div>
<div class="locations-list-separator"></div>
<div class="locations-list-text" role="cell">${location.long}</div>
<div class="locations-list-separator"></div>
<div class="locations-list-text" role="cell">${location.timezoneId}</div>
<div class="locations-list-separator"></div>
<div class="locations-list-text" role="cell">${location.locale}</div>
<div class="locations-list-separator"></div>
<div class="locations-list-text" role="cell">${
location.accuracy ?? SDK.EmulationModel.Location.DEFAULT_ACCURACY}</div>
</div>`;
// clang-format on
}
export interface EditorInputControls {
titleInput: LitTemplate|Element;
latInput: LitTemplate|Element;
longInput: LitTemplate|Element;
timezoneIdInput: LitTemplate|Element;
localeInput: LitTemplate|Element;
accuracyInput: LitTemplate|Element;
}
export interface LocationValidationErrors {
title?: string|null;
lat?: string|null;
long?: string|null;
timezoneId?: string|null;
locale?: string|null;
accuracy?: string|null;
}
export interface LocationDialogInput {
location: LocationDescription;
isNew: boolean;
errors?: LocationValidationErrors;
onSave: (location: LocationDescription) => void;
onCancel: () => void;
onValidateErrors: (errors: LocationValidationErrors) => void;
}
export function renderEditorView(controls: EditorInputControls, errors?: LocationDialogInput['errors'],
isDialog = false): LitTemplate {
if (!isDialog) {
// clang-format off
return html`
<div class="locations-edit-row">
<div class="locations-list-text locations-list-title">${i18nString(UIStrings.locationName)}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text">${i18nString(UIStrings.lat)}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text">${i18nString(UIStrings.long)}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text">${i18nString(UIStrings.timezoneId)}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text">${i18nString(UIStrings.locale)}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text">${i18nString(UIStrings.accuracy)}</div>
</div>
<div class="locations-edit-row">
<div class="locations-list-text locations-list-title locations-input-container">${controls.titleInput}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-input-container">${controls.latInput}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-list-text-longitude locations-input-container">${controls.longInput}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-input-container">${controls.timezoneIdInput}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-input-container">${controls.localeInput}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-input-container">${controls.accuracyInput}</div>
</div>
${errors ? html`
<div class="locations-edit-row locations-error-row" role="alert">
<div class="locations-list-text locations-list-title locations-error-cell">${errors.title ?? nothing}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-error-cell">${errors.lat ?? nothing}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-list-text-longitude locations-error-cell">${errors.long ?? nothing}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-error-cell">${errors.timezoneId ?? nothing}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-error-cell">${errors.locale ?? nothing}</div>
<div class="locations-list-separator locations-list-separator-invisible"></div>
<div class="locations-list-text locations-error-cell">${errors.accuracy ?? nothing}</div>
</div>
` : nothing}`;
// clang-format on
}
// clang-format off
return html`
<div class="editor-grid">
<div class="editor-field editor-field-full-width ${errors?.title ? 'has-error' : ''}">
<label class="editor-field-label" for="location-title">${i18nString(UIStrings.locationName)}</label>
${controls.titleInput}
${errors?.title ? html`<div class="editor-field-error" role="alert">${errors.title}</div>` : nothing}
</div>
<div class="editor-field ${errors?.lat ? 'has-error' : ''}">
<label class="editor-field-label" for="location-lat">${i18nString(UIStrings.lat)}</label>
${controls.latInput}
${errors?.lat ? html`<div class="editor-field-error" role="alert">${errors.lat}</div>` : nothing}
</div>
<div class="editor-field ${errors?.long ? 'has-error' : ''}">
<label class="editor-field-label" for="location-long">${i18nString(UIStrings.long)}</label>
${controls.longInput}
${errors?.long ? html`<div class="editor-field-error" role="alert">${errors.long}</div>` : nothing}
</div>
<div class="editor-field ${errors?.timezoneId ? 'has-error' : ''}">
<label class="editor-field-label" for="location-timezone">${i18nString(UIStrings.timezoneId)}</label>
${controls.timezoneIdInput}
${errors?.timezoneId ? html`<div class="editor-field-error" role="alert">${errors.timezoneId}</div>` : nothing}
</div>
<div class="editor-field ${errors?.locale ? 'has-error' : ''}">
<label class="editor-field-label" for="location-locale">${i18nString(UIStrings.locale)}</label>
${controls.localeInput}
${errors?.locale ? html`<div class="editor-field-error" role="alert">${errors.locale}</div>` : nothing}
</div>
<div class="editor-field editor-field-full-width ${errors?.accuracy ? 'has-error' : ''}">
<label class="editor-field-label" for="location-accuracy">${i18nString(UIStrings.accuracy)}</label>
${controls.accuracyInput}
${errors?.accuracy ? html`<div class="editor-field-error" role="alert">${errors.accuracy}</div>` : nothing}
</div>
</div>
`;
// clang-format on
}
export function renderLocationDialog(input: LocationDialogInput): LitTemplate {
const titleInputRef = createRef<HTMLInputElement>();
const latInputRef = createRef<HTMLInputElement>();
const longInputRef = createRef<HTMLInputElement>();
const timezoneIdInputRef = createRef<HTMLInputElement>();
const localeInputRef = createRef<HTMLInputElement>();
const accuracyInputRef = createRef<HTMLInputElement>();
const handleSave = (): void => {
const title = titleInputRef.value?.value.trim() ?? '';
const latStr = latInputRef.value?.value.trim() ?? '';
const longStr = longInputRef.value?.value.trim() ?? '';
const timezoneId = timezoneIdInputRef.value?.value.trim() ?? '';
const locale = localeInputRef.value?.value.trim() ?? '';
const accuracyStr = accuracyInputRef.value?.value.trim() ?? '';
const titleError = validateTitle(title);
const latError = validateLatitude(latStr);
const longError = validateLongitude(longStr);
const tzError = validateTimezoneId(timezoneId);
const localeError = validateLocale(locale);
const accuracyError = validateAccuracy(accuracyStr);
if (titleError || latError || longError || tzError || localeError || accuracyError) {
input.onValidateErrors({
title: titleError,
lat: latError,
long: longError,
timezoneId: tzError,
locale: localeError,
accuracy: accuracyError,
});
return;
}
input.onSave({
title,
lat: latStr ? parseFloat(latStr) : 0,
long: longStr ? parseFloat(longStr) : 0,
timezoneId,
locale,
accuracy: accuracyStr ? parseFloat(accuracyStr) : SDK.EmulationModel.Location.DEFAULT_ACCURACY,
});
};
const editorControls: EditorInputControls = {
titleInput:
html`<input id="location-title" aria-label=${i18nString(UIStrings.locationName)} type="text" placeholder=${
i18nString(UIStrings.locationName)} .value=${input.location.title} ${ref(titleInputRef)}>`,
latInput: html`<input id="location-lat" aria-label=${i18nString(UIStrings.lat)} type="text" placeholder=${
i18nString(UIStrings.latitude)} .value=${String(input.location.lat)} ${ref(latInputRef)}>`,
longInput: html`<input id="location-long" aria-label=${i18nString(UIStrings.long)} type="text" placeholder=${
i18nString(UIStrings.longitude)} .value=${String(input.location.long)} ${ref(longInputRef)}>`,
timezoneIdInput:
html`<input id="location-timezone" aria-label=${i18nString(UIStrings.timezoneId)} type="text" placeholder=${
i18nString(UIStrings.timezoneId)} .value=${input.location.timezoneId} ${ref(timezoneIdInputRef)}>`,
localeInput: html`<input id="location-locale" aria-label=${i18nString(UIStrings.locale)} type="text" placeholder=${
i18nString(UIStrings.locale)} .value=${input.location.locale} ${ref(localeInputRef)}>`,
accuracyInput: html`<input id="location-accuracy" aria-label=${
i18nString(UIStrings.accuracy)} type="text" placeholder=${i18nString(UIStrings.accuracy)} .value=${
String(input.location.accuracy ?? SDK.EmulationModel.Location.DEFAULT_ACCURACY)} ${ref(accuracyInputRef)}>`,
};
// clang-format off
const dialogContent = html`
<style>${locationsSettingsTabStyles}</style>
<div class="location-dialog-content">
<div class="dialog-header">
<span class="dialog-title">${input.isNew ? i18nString(UIStrings.addLocation) : i18nString(UIStrings.editLocation)}</span>
<devtools-button
class="dialog-close-button"
.iconName=${'cross'}
.variant=${Buttons.Button.Variant.ICON}
.title=${i18nString(UIStrings.close)}
.jslogContext=${'dialog-close'}
@click=${input.onCancel}
aria-label=${i18nString(UIStrings.close)}>
</devtools-button>
</div>
${renderEditorView(editorControls, input.errors, true)}
<div class="dialog-buttons">
<devtools-button
class="save-button"
.variant=${Buttons.Button.Variant.PRIMARY}
@click=${handleSave}>
${i18nString(UIStrings.save)}
</devtools-button>
<devtools-button
class="cancel-button"
.variant=${Buttons.Button.Variant.OUTLINED}
@click=${input.onCancel}>
${i18nString(UIStrings.cancel)}
</devtools-button>
</div>
</div>
`;
return html`
<style>${locationsSettingsTabStyles}</style>
<devtools-widget
class="location-dialog-widget"
${UI.Widget.widget(UI.Dialog.DialogWidget, {
open: true,
jslogContext: 'location-dialog',
dialogStack: true,
content: dialogContent,
})}
@hidden=${input.onCancel}>
</devtools-widget>
`;
// clang-format on
}
interface LocationsViewInput {
locations: LocationDescription[];
onAddLocation: () => void;
onEditLocation: (index: number) => void;
onRemoveLocation: (index: number) => void;
activeDialog?: LocationDialogInput;
}
export type ViewOutput = undefined;
export const DEFAULT_VIEW = (input: LocationsViewInput, _output: ViewOutput, target: HTMLElement): void => {
// clang-format off
render(html`
<style>${locationsSettingsTabStyles}</style>
<div class="settings-card-container-wrapper">
<div class="settings-card-container">
<devtools-card .heading=${i18nString(UIStrings.locations)}>
<div>
${input.locations.length > 0 ? html`
<devtools-list
class="locations-list square-corners"
.editable=${true}
.deletable=${true}
@edit=${(e: Lists.List.ItemEditEvent) => input.onEditLocation(e.detail.index)}
@delete=${(e: Lists.List.ItemRemoveEvent) => input.onRemoveLocation(e.detail.index)}>
${input.locations.map(location => renderItemView(location))}
</devtools-list>
` : nothing}
</div>
<devtools-button
class="add-locations-button"
.variant=${Buttons.Button.Variant.OUTLINED}
.iconName=${'plus'}
.jslogContext=${'emulation.add-location'}
@click=${input.onAddLocation}>
${i18nString(UIStrings.addLocation)}
</devtools-button>
</devtools-card>
</div>
</div>
${input.activeDialog ? renderLocationDialog(input.activeDialog) : nothing}
`, target);
// clang-format on
};
export type View = typeof DEFAULT_VIEW;
export class LocationsSettingsTab extends UI.Widget.VBox {
private readonly customSetting: Common.Settings.Setting<LocationDescription[]>;
#view: View;
#activeDialogState?: {
location: LocationDescription,
isNew: boolean,
index?: number,
errors?: LocationValidationErrors,
};
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element, {
jslog: `${VisualLogging.pane('emulation-locations')}`,
useShadowDom: true,
});
this.#view = view;
this.customSetting =
Common.Settings.Settings.instance().moduleSetting<LocationDescription[]>('emulation.locations');
const list =
this.customSetting.get().map(location => replaceLocationTitles(location, this.customSetting.defaultValue));
function replaceLocationTitles(
location: LocationDescription, defaultValues: LocationDescription[]): LocationDescription {
// This check is done for locations that might had been cached wrongly due to crbug.com/1171670.
// Each of the default values would have been stored without a title if the user had added a new location
// while the bug was present in the application. This means that getting the setting's default value with the `get`
// method would return the default locations without a title. To cope with this, the setting values are
// preemptively checked and corrected so that any default value mistakenly stored without a title is replaced
// with the corresponding declared value in the pre-registered setting.
if (!location.title) {
const replacement = defaultValues.find(
defaultLocation => defaultLocation.lat === location.lat && defaultLocation.long === location.long &&
defaultLocation.timezoneId === location.timezoneId && defaultLocation.locale === location.locale);
if (!replacement) {
console.error('Could not determine a location setting title');
} else {
return replacement;
}
}
return location;
}
this.customSetting.set(list);
this.customSetting.addChangeListener(this.locationsUpdated, this);
}
override wasShown(): void {
super.wasShown();
this.locationsUpdated();
}
override performUpdate(): void {
let activeDialogInput: LocationDialogInput|undefined;
if (this.#activeDialogState) {
const state = this.#activeDialogState;
activeDialogInput = {
location: state.location,
isNew: state.isNew,
errors: state.errors,
onSave: (updatedLocation: LocationDescription) => this.saveDialog(updatedLocation),
onCancel: () => this.closeDialog(),
onValidateErrors: (errors: LocationValidationErrors) => this.updateDialogErrors(errors),
};
}
const viewInput: LocationsViewInput = {
locations: this.customSetting.get(),
onAddLocation: () => this.addButtonClicked(),
onEditLocation: (index: number) => this.editLocationClicked(index),
onRemoveLocation: (index: number) => this.removeLocationClicked(index),
activeDialog: activeDialogInput,
};
this.#view(viewInput, undefined, this.contentElement as HTMLElement);
}
private locationsUpdated(): void {
this.requestUpdate();
}
private addButtonClicked(): void {
this.#activeDialogState = {
location: {
title: '',
lat: 0,
long: 0,
timezoneId: '',
locale: '',
accuracy: SDK.EmulationModel.Location.DEFAULT_ACCURACY,
},
isNew: true,
};
this.requestUpdate();
}
private editLocationClicked(index: number): void {
const list = this.customSetting.get();
const location = list[index];
if (!location) {
return;
}
this.#activeDialogState = {
location: {...location},
isNew: false,
index,
};
this.requestUpdate();
}
private removeLocationClicked(index: number): void {
const list = this.customSetting.get();
list.splice(index, 1);
this.customSetting.set(list);
}
private saveDialog(updatedLocation: LocationDescription): void {
if (!this.#activeDialogState) {
return;
}
const list = this.customSetting.get();
if (this.#activeDialogState.isNew) {
list.push(updatedLocation);
} else if (this.#activeDialogState.index !== undefined) {
list[this.#activeDialogState.index] = updatedLocation;
}
this.#activeDialogState = undefined;
this.customSetting.set(list);
}
private closeDialog(): void {
this.#activeDialogState = undefined;
this.requestUpdate();
}
private updateDialogErrors(errors: LocationValidationErrors): void {
if (this.#activeDialogState) {
this.#activeDialogState.errors = errors;
this.requestUpdate();
}
}
}
export interface LocationDescription {
title: string;
lat: number;
long: number;
timezoneId: string;
locale: string;
accuracy?: number;
}
export function validateTitle(value: string): string|null {
const maxLength = 50;
const trimmedValue = value.trim();
if (!trimmedValue.length) {
return i18nString(UIStrings.locationNameCannotBeEmpty);
}
if (trimmedValue.length > maxLength) {
return i18nString(UIStrings.locationNameMustBeLessThanS, {PH1: maxLength});
}
return null;
}
export function validateLatitude(value: string): string|null {
const minLat = -90;
const maxLat = 90;
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (!trimmedValue) {
return null;
}
if (Number.isNaN(parsedValue)) {
return i18nString(UIStrings.latitudeMustBeANumber);
}
if (parsedValue < minLat) {
return i18nString(UIStrings.latitudeMustBeGreaterThanOrEqual, {PH1: minLat});
}
if (parsedValue > maxLat) {
return i18nString(UIStrings.latitudeMustBeLessThanOrEqualToS, {PH1: maxLat});
}
return null;
}
export function validateLongitude(value: string): string|null {
const minLong = -180;
const maxLong = 180;
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (!trimmedValue) {
return null;
}
if (Number.isNaN(parsedValue)) {
return i18nString(UIStrings.longitudeMustBeANumber);
}
if (parsedValue < minLong) {
return i18nString(UIStrings.longitudeMustBeGreaterThanOr, {PH1: minLong});
}
if (parsedValue > maxLong) {
return i18nString(UIStrings.longitudeMustBeLessThanOrEqualTo, {PH1: maxLong});
}
return null;
}
export function validateTimezoneId(value: string): string|null {
const trimmedValue = value.trim();
// Chromium uses ICU's timezone implementation, which is very
// liberal in what it accepts. ICU does not simply use an allowlist
// but instead tries to make sense of the input, even for
// weird-looking timezone IDs. There's not much point in validating
// the input other than checking if it contains at least one
// alphabetic character. The empty string resets the override,
// and is accepted as well.
if (trimmedValue === '' || /[a-zA-Z]/.test(trimmedValue)) {
return null;
}
return i18nString(UIStrings.timezoneIdMustContainAlphabetic);
}
export function validateLocale(value: string): string|null {
const trimmedValue = value.trim();
// Similarly to timezone IDs, there's not much point in validating
// input locales other than checking if it contains at least two
// alphabetic characters.
// https://unicode.org/reports/tr35/#Unicode_language_identifier
// The empty string resets the override, and is accepted as
// well.
if (trimmedValue === '' || /[a-zA-Z]{2}/.test(trimmedValue)) {
return null;
}
return i18nString(UIStrings.localeMustContainAlphabetic);
}
export function validateAccuracy(value: string): string|null {
const minAccuracy = 0;
const trimmedValue = value.trim();
const parsedValue = Number(trimmedValue);
if (!trimmedValue) {
return null;
}
if (Number.isNaN(parsedValue)) {
return i18nString(UIStrings.accuracyMustBeANumber);
}
if (parsedValue < minAccuracy) {
return i18nString(UIStrings.accuracyMustBeGreaterThanOrEqual, {PH1: minAccuracy});
}
return null;
}