| // Copyright 2025 The Chromium Authors |
| // 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 Geometry from '../../models/geometry/geometry.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import type {WidgetOptions} from '../../ui/legacy/Widget.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} = Geometry; |
| const {repeat} = LitDirectives; |
| |
| type Widget = UI.Widget.Widget; |
| type VBox = UI.Widget.VBox; |
| |
| const UIStrings = { |
| /** |
| * @description Text that shows in the Application 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: (item: {key: string, value: string}|null) => void; |
| onSort: (ascending: boolean) => void; |
| onCreate: (key: string, value: string) => void; |
| onReferesh: () => void; |
| onEdit: (key: string, value: string, columnId: string, valueBeforeEditing: string, newText: string) => void; |
| onDelete: (key: string) => 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, opts?: WidgetOptions) { |
| 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" |
| @sort=${(e: CustomEvent<{columnId: string, ascending: boolean}>) => input.onSort(e.detail.ascending)} |
| @refresh=${input.onReferesh} |
| @create=${(e: CustomEvent<{key: string, value: string}>) => input.onCreate(e.detail.key, e.detail.value)} |
| @deselect=${() => input.onSelect(null)} |
| > |
| <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} |
| @select=${() => input.onSelect(item)} |
| @edit=${(e: CustomEvent<{columnId: string, valueBeforeEditing: string, newText: string}>) => |
| input.onEdit(item.key, item.value, e.detail.columnId, e.detail.valueBeforeEditing, e.detail.newText)} |
| @delete=${() => input.onDelete(item.key)} |
| 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); |
| }; |
| } |
| super(opts); |
| 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 { |
| super.wasShown(); |
| 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: (item: {key: string, value: string}|null) => { |
| this.#toolbar?.setCanDeleteSelected(Boolean(item)); |
| if (!item) { |
| void this.#previewEntry(null); |
| } else { |
| void this.#previewEntry(item); |
| } |
| }, |
| onSort: (ascending: boolean) => { |
| this.#isSortOrderAscending = ascending; |
| }, |
| onCreate: (key: string, value: string) => { |
| this.#createCallback(key, value); |
| }, |
| onEdit: (key: string, value: string, columnId: string, valueBeforeEditing: string, newText: string) => { |
| this.#editingCallback(key, value, columnId, valueBeforeEditing, newText); |
| }, |
| onDelete: (key: string) => { |
| this.#deleteCallback(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.LiveAnnouncer.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(key: string, value: string, 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, value); |
| this.#removeDupes(newText, value); |
| void this.#previewEntry({key: newText, value}); |
| } else { |
| this.setItem(key, newText); |
| void this.#previewEntry({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>; |
| } |