blob: 6f4d782e01df697cc4ddf4784bde3cb694a72dec [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as UI from '../../ui/legacy/legacy.js';
import locationsSettingsTabStyles from './locationsSettingsTab.css.js';
let locationsSettingsTabInstance: LocationsSettingsTab;
const UIStrings = {
/**
*@description Title in the Locations Settings Tab, where custom geographic locations that the user
*has entered are stored.
*/
customLocations: 'Custom locations',
/**
*@description Label for the name of a geographic location that the user has entered.
*/
locationName: 'Location name',
/**
*@description Abbreviation of latitude in Locations Settings Tab of the Device Toolbar
*/
lat: 'Lat',
/**
*@description Abbreviation of longitude in Locations Settings Tab of the Device Toolbar
*/
long: 'Long',
/**
*@description Text in 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 Error message in the Locations settings pane that declares the location name input must not be empty
*/
locationNameCannotBeEmpty: 'Location name cannot be empty',
/**
*@description Error message in the Locations settings pane 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 pane 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 pane 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 pane 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 pane 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 pane 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 pane 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 pane that declares timezone ID input invalid
*/
timezoneIdMustContainAlphabetic: 'Timezone ID must contain alphabetic characters',
/**
*@description Error message in the Locations settings pane that declares locale input invalid
*/
localeMustContainAlphabetic: 'Locale must contain alphabetic characters',
/**
*@description Text of add locations button in Locations Settings Tab of the Device Toolbar
*/
addLocation: 'Add location...',
};
const str_ = i18n.i18n.registerUIStrings('panels/sensors/LocationsSettingsTab.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class LocationsSettingsTab extends UI.Widget.VBox implements UI.ListWidget.Delegate<LocationDescription> {
private readonly list: UI.ListWidget.ListWidget<LocationDescription>;
private readonly customSetting: Common.Settings.Setting<LocationDescription[]>;
private editor?: UI.ListWidget.Editor<LocationDescription>;
private constructor() {
super(true);
this.contentElement.createChild('div', 'header').textContent = i18nString(UIStrings.customLocations);
const addButton = UI.UIUtils.createTextButton(
i18nString(UIStrings.addLocation), this.addButtonClicked.bind(this), 'add-locations-button');
this.contentElement.appendChild(addButton);
this.list = new UI.ListWidget.ListWidget(this);
this.list.element.classList.add('locations-list');
this.list.show(this.contentElement);
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);
this.setDefaultFocusedElement(addButton);
}
static instance(): LocationsSettingsTab {
if (!locationsSettingsTabInstance) {
locationsSettingsTabInstance = new LocationsSettingsTab();
}
return locationsSettingsTabInstance;
}
wasShown(): void {
super.wasShown();
this.registerCSSFiles([locationsSettingsTabStyles]);
this.list.registerCSSFiles([locationsSettingsTabStyles]);
this.locationsUpdated();
}
private locationsUpdated(): void {
this.list.clear();
const conditions = this.customSetting.get();
for (const condition of conditions) {
this.list.appendItem(condition, true);
}
this.list.appendSeparator();
}
private addButtonClicked(): void {
this.list.addNewItem(this.customSetting.get().length, {title: '', lat: 0, long: 0, timezoneId: '', locale: ''});
}
renderItem(location: LocationDescription, _editable: boolean): Element {
const element = document.createElement('div');
element.classList.add('locations-list-item');
const title = element.createChild('div', 'locations-list-text locations-list-title');
const titleText = title.createChild('div', 'locations-list-title-text');
titleText.textContent = location.title;
UI.Tooltip.Tooltip.install(titleText, location.title);
element.createChild('div', 'locations-list-separator');
element.createChild('div', 'locations-list-text').textContent = String(location.lat);
element.createChild('div', 'locations-list-separator');
element.createChild('div', 'locations-list-text').textContent = String(location.long);
element.createChild('div', 'locations-list-separator');
element.createChild('div', 'locations-list-text').textContent = location.timezoneId;
element.createChild('div', 'locations-list-separator');
element.createChild('div', 'locations-list-text').textContent = location.locale;
return element;
}
removeItemRequested(item: LocationDescription, index: number): void {
const list = this.customSetting.get();
list.splice(index, 1);
this.customSetting.set(list);
}
commitEdit(location: LocationDescription, editor: UI.ListWidget.Editor<LocationDescription>, isNew: boolean): void {
location.title = editor.control('title').value.trim();
const lat = editor.control('lat').value.trim();
location.lat = lat ? parseFloat(lat) : 0;
const long = editor.control('long').value.trim();
location.long = long ? parseFloat(long) : 0;
const timezoneId = editor.control('timezoneId').value.trim();
location.timezoneId = timezoneId;
const locale = editor.control('locale').value.trim();
location.locale = locale;
const list = this.customSetting.get();
if (isNew) {
list.push(location);
}
this.customSetting.set(list);
}
beginEdit(location: LocationDescription): UI.ListWidget.Editor<LocationDescription> {
const editor = this.createEditor();
editor.control('title').value = location.title;
editor.control('lat').value = String(location.lat);
editor.control('long').value = String(location.long);
editor.control('timezoneId').value = location.timezoneId;
editor.control('locale').value = location.locale;
return editor;
}
private createEditor(): UI.ListWidget.Editor<LocationDescription> {
if (this.editor) {
return this.editor;
}
const editor = new UI.ListWidget.Editor<LocationDescription>();
this.editor = editor;
const content = editor.contentElement();
const titles = content.createChild('div', 'locations-edit-row');
titles.createChild('div', 'locations-list-text locations-list-title').textContent =
i18nString(UIStrings.locationName);
titles.createChild('div', 'locations-list-separator locations-list-separator-invisible');
titles.createChild('div', 'locations-list-text').textContent = i18nString(UIStrings.lat);
titles.createChild('div', 'locations-list-separator locations-list-separator-invisible');
titles.createChild('div', 'locations-list-text').textContent = i18nString(UIStrings.long);
titles.createChild('div', 'locations-list-separator locations-list-separator-invisible');
titles.createChild('div', 'locations-list-text').textContent = i18nString(UIStrings.timezoneId);
titles.createChild('div', 'locations-list-separator locations-list-separator-invisible');
titles.createChild('div', 'locations-list-text').textContent = i18nString(UIStrings.locale);
const fields = content.createChild('div', 'locations-edit-row');
fields.createChild('div', 'locations-list-text locations-list-title locations-input-container')
.appendChild(editor.createInput('title', 'text', i18nString(UIStrings.locationName), titleValidator));
fields.createChild('div', 'locations-list-separator locations-list-separator-invisible');
let cell = fields.createChild('div', 'locations-list-text locations-input-container');
cell.appendChild(editor.createInput('lat', 'text', i18nString(UIStrings.latitude), latValidator));
fields.createChild('div', 'locations-list-separator locations-list-separator-invisible');
cell = fields.createChild('div', 'locations-list-text locations-list-text-longitude locations-input-container');
cell.appendChild(editor.createInput('long', 'text', i18nString(UIStrings.longitude), longValidator));
fields.createChild('div', 'locations-list-separator locations-list-separator-invisible');
cell = fields.createChild('div', 'locations-list-text locations-input-container');
cell.appendChild(editor.createInput('timezoneId', 'text', i18nString(UIStrings.timezoneId), timezoneIdValidator));
fields.createChild('div', 'locations-list-separator locations-list-separator-invisible');
cell = fields.createChild('div', 'locations-list-text locations-input-container');
cell.appendChild(editor.createInput('locale', 'text', i18nString(UIStrings.locale), localeValidator));
return editor;
function titleValidator(
item: LocationDescription, index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult {
const maxLength = 50;
const value = input.value.trim();
let errorMessage;
if (!value.length) {
errorMessage = i18nString(UIStrings.locationNameCannotBeEmpty);
} else if (value.length > maxLength) {
errorMessage = i18nString(UIStrings.locationNameMustBeLessThanS, {PH1: maxLength});
}
if (errorMessage) {
return {valid: false, errorMessage};
}
return {valid: true, errorMessage: undefined};
}
function latValidator(
item: LocationDescription, index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult {
const minLat = -90;
const maxLat = 90;
const value = input.value.trim();
const parsedValue = Number(value);
if (!value) {
return {valid: true, errorMessage: undefined};
}
let errorMessage;
if (Number.isNaN(parsedValue)) {
errorMessage = i18nString(UIStrings.latitudeMustBeANumber);
} else if (parseFloat(value) < minLat) {
errorMessage = i18nString(UIStrings.latitudeMustBeGreaterThanOrEqual, {PH1: minLat});
} else if (parseFloat(value) > maxLat) {
errorMessage = i18nString(UIStrings.latitudeMustBeLessThanOrEqualToS, {PH1: maxLat});
}
if (errorMessage) {
return {valid: false, errorMessage};
}
return {valid: true, errorMessage: undefined};
}
function longValidator(
item: LocationDescription, index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult {
const minLong = -180;
const maxLong = 180;
const value = input.value.trim();
const parsedValue = Number(value);
if (!value) {
return {valid: true, errorMessage: undefined};
}
let errorMessage;
if (Number.isNaN(parsedValue)) {
errorMessage = i18nString(UIStrings.longitudeMustBeANumber);
} else if (parseFloat(value) < minLong) {
errorMessage = i18nString(UIStrings.longitudeMustBeGreaterThanOr, {PH1: minLong});
} else if (parseFloat(value) > maxLong) {
errorMessage = i18nString(UIStrings.longitudeMustBeLessThanOrEqualTo, {PH1: maxLong});
}
if (errorMessage) {
return {valid: false, errorMessage};
}
return {valid: true, errorMessage: undefined};
}
function timezoneIdValidator(
item: LocationDescription, index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult {
const value = input.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 (value === '' || /[a-zA-Z]/.test(value)) {
return {valid: true, errorMessage: undefined};
}
const errorMessage = i18nString(UIStrings.timezoneIdMustContainAlphabetic);
return {valid: false, errorMessage};
}
function localeValidator(
item: LocationDescription, index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult {
const value = input.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 (value === '' || /[a-zA-Z]{2}/.test(value)) {
return {valid: true, errorMessage: undefined};
}
const errorMessage = i18nString(UIStrings.localeMustContainAlphabetic);
return {valid: false, errorMessage};
}
}
}
export interface LocationDescription {
title: string;
lat: number;
long: number;
timezoneId: string;
locale: string;
}