chrome-devtools-frontend
Version:
Chrome DevTools UI
391 lines (355 loc) • 14.2 kB
text/typescript
// Copyright 2025 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.
/*
* Copyright (C) 2008 Nokia Inc. All rights reserved.
* Copyright (C) 2013 Samsung Electronics. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* eslint no-return-assign: "off" */
import * as i18n from '../../core/i18n/i18n.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives as LitDirectives, html, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as ApplicationComponents from './components/components.js';
import {StorageItemsToolbar} from './StorageItemsToolbar.js';
const {ARIAUtils} = UI;
const {EmptyWidget} = UI.EmptyWidget;
const {VBox, widgetConfig} = UI.Widget;
const {Size} = UI.Geometry;
const {repeat} = LitDirectives;
type Widget = UI.Widget.Widget;
type VBox = UI.Widget.VBox;
const UIStrings = {
/**
*@description Text that shows in the Applicaiton Panel if no value is selected for preview
*/
noPreviewSelected: 'No value selected',
/**
*@description Preview text when viewing storage in Application panel
*/
selectAValueToPreview: 'Select a value to preview',
/**
*@description Text for announcing number of entries after filtering
*@example {5} PH1
*/
numberEntries: 'Number of entries shown in table: {PH1}',
/**
*@description Text in DOMStorage Items View of the Application panel
*/
key: 'Key',
/**
*@description Text for the value of something
*/
value: 'Value',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/application/KeyValueStorageItemsView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface ViewInput {
items: Array<{key: string, value: string}>;
selectedKey: string|null;
editable: boolean;
preview: Widget;
onSelect: (event: CustomEvent<HTMLElement|null>) => void;
onSort: (event: CustomEvent<{columnId: string, ascending: boolean}>) => void;
onCreate: (event: CustomEvent<{key: string, value: string}>) => void;
onReferesh: () => void;
onEdit:
(event: CustomEvent<{node: HTMLElement, columnId: string, valueBeforeEditing: string, newText: string}>) => void;
onDelete: (event: CustomEvent<HTMLElement>) => void;
}
interface ViewOutput {
toolbar: StorageItemsToolbar;
}
const MAX_VALUE_LENGTH = 4096;
export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void;
/**
* A helper typically used in the Application panel. Renders a split view
* between a DataGrid displaying key-value pairs and a preview Widget.
*/
export abstract class KeyValueStorageItemsView extends UI.Widget.VBox {
#preview: Widget;
#previewValue: string|null;
#items: Array<{key: string, value: string}> = [];
#selectedKey: string|null = null;
#view: View;
#isSortOrderAscending = true;
#editable: boolean;
#toolbar: StorageItemsToolbar|undefined;
readonly metadataView: ApplicationComponents.StorageMetadataView.StorageMetadataView;
constructor(
title: string, id: string, editable: boolean, view?: View,
metadataView?: ApplicationComponents.StorageMetadataView.StorageMetadataView) {
metadataView ??= new ApplicationComponents.StorageMetadataView.StorageMetadataView();
if (!view) {
view = (input: ViewInput, output: ViewOutput, target: HTMLElement) => {
// clang-format off
render(html `
<devtools-widget
.widgetConfig=${widgetConfig(StorageItemsToolbar, {metadataView})}
class=flex-none
${UI.Widget.widgetRef(StorageItemsToolbar, view => {output.toolbar = view;})}
></devtools-widget>
<devtools-split-view sidebar-position="second" name="${id}-split-view-state">
<devtools-widget
slot="main"
.widgetConfig=${widgetConfig(VBox, {minimumSize: new Size(0, 50)})}>
<devtools-data-grid
.name=${`${id}-datagrid-with-preview`}
striped
style="flex: auto"
=${input.onSelect}
=${input.onSort}
=${input.onReferesh}
=${input.onCreate}
=${input.onEdit}
=${input.onDelete}
>
<table>
<tr>
<th id="key" sortable ?editable=${input.editable}>
${i18nString(UIStrings.key)}
</th>
<th id="value" ?editable=${input.editable}>
${i18nString(UIStrings.value)}
</th>
</tr>
${repeat(input.items, item => item.key, item => html`
<tr data-key=${item.key} data-value=${item.value}
selected=${(input.selectedKey === item.key) || nothing}>
<td>${item.key}</td>
<td>${item.value.substr(0, MAX_VALUE_LENGTH)}</td>
</tr>`)}
<tr placeholder></tr>
</table>
</devtools-data-grid>
</devtools-widget>
<devtools-widget
slot="sidebar"
.widgetConfig=${widgetConfig(VBox, {minimumSize: new Size(0, 50)})}
jslog=${VisualLogging.pane('preview').track({resize: true})}>
${input.preview?.element}
</devtools-widget>
</devtools-split-view>`,
// clang-format on
target, {host: input});
};
}
super(false);
this.metadataView = metadataView;
this.#editable = editable;
this.#view = view;
this.performUpdate();
this.#preview =
new EmptyWidget(i18nString(UIStrings.noPreviewSelected), i18nString(UIStrings.selectAValueToPreview));
this.#previewValue = null;
this.showPreview(null, null);
}
override wasShown(): void {
this.refreshItems();
}
override performUpdate(): void {
const that = this;
const viewOutput = {
set toolbar(toolbar: StorageItemsToolbar) {
that.#toolbar?.removeEventListener(StorageItemsToolbar.Events.DELETE_SELECTED, that.deleteSelectedItem, that);
that.#toolbar?.removeEventListener(StorageItemsToolbar.Events.DELETE_ALL, that.deleteAllItems, that);
that.#toolbar?.removeEventListener(StorageItemsToolbar.Events.REFRESH, that.refreshItems, that);
that.#toolbar = toolbar;
that.#toolbar.addEventListener(StorageItemsToolbar.Events.DELETE_SELECTED, that.deleteSelectedItem, that);
that.#toolbar.addEventListener(StorageItemsToolbar.Events.DELETE_ALL, that.deleteAllItems, that);
that.#toolbar.addEventListener(StorageItemsToolbar.Events.REFRESH, that.refreshItems, that);
}
};
const viewInput = {
items: this.#items,
selectedKey: this.#selectedKey,
editable: this.#editable,
preview: this.#preview,
onSelect: (event: CustomEvent<HTMLElement|null>) => {
this.#toolbar?.setCanDeleteSelected(Boolean(event.detail));
if (!event.detail) {
void this.#previewEntry(null);
} else {
void this.#previewEntry({key: event.detail.dataset.key || '', value: event.detail.dataset.value || ''});
}
},
onSort: (event: CustomEvent<{columnId: string, ascending: boolean}>) => {
this.#isSortOrderAscending = event.detail.ascending;
},
onCreate: (event: CustomEvent<{key: string, value: string}>) => {
this.#createCallback(event.detail.key, event.detail.value);
},
onEdit:
(event: CustomEvent<{node: HTMLElement, columnId: string, valueBeforeEditing: string, newText: string}>) => {
this.#editingCallback(
event.detail.node, event.detail.columnId, event.detail.valueBeforeEditing, event.detail.newText);
},
onDelete: (event: CustomEvent<HTMLElement>) => {
this.#deleteCallback(event.detail.dataset.key || '');
},
onReferesh: () => {
this.refreshItems();
},
};
this.#view(viewInput, viewOutput, this.contentElement);
}
protected get toolbar(): StorageItemsToolbar|undefined {
return this.#toolbar;
}
refreshItems(): void {
}
deleteAllItems(): void {
}
itemsCleared(): void {
this.#items = [];
this.performUpdate();
this.#toolbar?.setCanDeleteSelected(false);
}
itemRemoved(key: string): void {
const index = this.#items.findIndex(item => item.key === key);
if (index === -1) {
return;
}
this.#items.splice(index, 1);
this.performUpdate();
this.#toolbar?.setCanDeleteSelected(this.#items.length > 1);
}
itemAdded(key: string, value: string): void {
if (this.#items.some(item => item.key === key)) {
return;
}
this.#items.push({key, value});
this.performUpdate();
}
itemUpdated(key: string, value: string): void {
const item = this.#items.find(item => item.key === key);
if (!item) {
return;
}
if (item.value === value) {
return;
}
item.value = value;
this.performUpdate();
if (this.#selectedKey !== key) {
return;
}
if (this.#previewValue !== value) {
void this.#previewEntry({key, value});
}
this.#toolbar?.setCanDeleteSelected(true);
}
showItems(items: Array<{key: string, value: string}>): void {
const sortDirection = this.#isSortOrderAscending ? 1 : -1;
this.#items = [...items].sort((item1, item2) => sortDirection * (item1.key > item2.key ? 1 : -1));
const selectedItem = this.#items.find(item => item.key === this.#selectedKey);
if (!selectedItem) {
this.#selectedKey = null;
} else {
void this.#previewEntry(selectedItem);
}
this.performUpdate();
this.#toolbar?.setCanDeleteSelected(Boolean(this.#selectedKey));
ARIAUtils.alert(i18nString(UIStrings.numberEntries, {PH1: this.#items.length}));
}
deleteSelectedItem(): void {
if (!this.#selectedKey) {
return;
}
this.#deleteCallback(this.#selectedKey);
}
#createCallback(key: string, value: string): void {
this.setItem(key, value);
this.#removeDupes(key, value);
void this.#previewEntry({key, value});
}
protected isEditAllowed(_columnIdentifier: string, _oldText: string, _newText: string): boolean {
return true;
}
#editingCallback(editingNode: HTMLElement, columnIdentifier: string, oldText: string, newText: string): void {
if (!this.isEditAllowed(columnIdentifier, oldText, newText)) {
return;
}
if (columnIdentifier === 'key') {
if (typeof oldText === 'string') {
this.removeItem(oldText);
}
this.setItem(newText, editingNode.dataset.value || '');
this.#removeDupes(newText, editingNode.dataset.value || '');
editingNode.dataset.key = newText;
void this.#previewEntry({key: newText, value: editingNode.dataset.value || ''});
} else {
this.setItem(editingNode.dataset.key || '', newText);
void this.#previewEntry({key: editingNode.dataset.key || '', value: newText});
}
}
#removeDupes(key: string, value: string): void {
for (let i = this.#items.length - 1; i >= 0; --i) {
const child = this.#items[i];
if ((child.key === key) && (value !== child.value)) {
this.#items.splice(i, 1);
}
}
}
#deleteCallback(key: string): void {
this.removeItem(key);
}
showPreview(preview: Widget|null, value: string|null): void {
if (this.#preview && this.#previewValue === value) {
return;
}
if (this.#preview) {
this.#preview.detach();
}
if (!preview) {
preview = new EmptyWidget(i18nString(UIStrings.noPreviewSelected), i18nString(UIStrings.selectAValueToPreview));
}
this.#previewValue = value;
this.#preview = preview;
this.performUpdate();
}
async #previewEntry(entry: {key: string, value: string}|null): Promise<void> {
if (entry?.value) {
this.#selectedKey = entry.key;
const preview = await this.createPreview(entry.key, entry.value);
// Selection could've changed while the preview was loaded
if (this.#selectedKey === entry.key) {
this.showPreview(preview, entry.value);
}
} else {
this.#selectedKey = null;
this.showPreview(null, null);
}
}
set editable(editable: boolean) {
this.#editable = editable;
this.performUpdate();
}
protected keys(): string[] {
return this.#items.map(item => item.key);
}
protected abstract setItem(key: string, value: string): void;
protected abstract removeItem(key: string): void;
protected abstract createPreview(key: string, value: string): Promise<Widget|null>;
}