blob: 020e1e786a739356f0c760826ea00f118ef873be [file]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {Button, type ButtonAttrs, ButtonVariant} from '../../../widgets/button';
import {TextInput} from '../../../widgets/text_input';
import {Icon} from '../../../widgets/icon';
import {TextParagraph} from '../../../widgets/text_paragraph';
import type {SqlTable} from '../../dev.perfetto.SqlModules/sql_modules';
import {perfettoSqlTypeToString} from '../../../trace_processor/perfetto_sql_type';
import {Callout} from '../../../widgets/callout';
import {Intent} from '../../../widgets/common';
import {EmptyState} from '../../../widgets/empty_state';
import type {QueryNode} from '../query_node';
import type {NodeModifySection} from '../node_types';
import {
PopupMultiSelect,
type MultiSelectOption,
type MultiSelectDiff,
} from '../../../widgets/multiselect';
import {classNames} from '../../../base/classnames';
import {Tooltip} from '../../../widgets/tooltip';
import {showModal} from '../../../widgets/modal';
import {Editor} from '../../../widgets/editor';
import {ResizeHandle} from '../../../widgets/resize_handle';
import {findRef, toHTMLElement} from '../../../base/dom_utils';
import {assertExists} from '../../../base/assert';
/**
* Round action button with consistent styling for Data Explorer.
* Used for undo/redo/add node buttons - provides unified styling with
* primary color, filled variant, and round shape.
*/
export interface RoundActionButtonAttrs {
readonly icon: string;
readonly title: string;
readonly onclick?: () => void;
readonly disabled?: boolean;
readonly className?: string;
}
export function RoundActionButton(attrs: RoundActionButtonAttrs) {
return m(Button, {
icon: attrs.icon,
title: attrs.title,
onclick: attrs.onclick,
disabled: attrs.disabled,
variant: ButtonVariant.Filled,
rounded: true,
iconFilled: true,
intent: Intent.Primary,
className: classNames('pf-qb-round-action-button', attrs.className),
});
}
// Empty state widget for the data explorer with warning variant support
export type ResultsPanelEmptyStateVariant = 'default' | 'warning';
export interface ResultsPanelEmptyStateAttrs {
readonly icon?: string;
readonly title?: string;
readonly variant?: ResultsPanelEmptyStateVariant;
}
export class ResultsPanelEmptyState
implements m.ClassComponent<ResultsPanelEmptyStateAttrs>
{
view({attrs, children}: m.CVnode<ResultsPanelEmptyStateAttrs>) {
const {icon, title, variant = 'default'} = attrs;
return m(
'.pf-results-panel-empty-state',
{
className: classNames(variant === 'warning' && 'pf-warning'),
},
icon &&
m(Icon, {
className: 'pf-data-explorer-empty-state__icon',
icon,
}),
title && m('.pf-results-panel-empty-state__message', title),
children,
);
}
}
// Re-export multiselect types for convenience
export type {MultiSelectOption, MultiSelectDiff};
// Action button definition for ListItem
export interface ListItemAction {
label?: string;
icon?: string;
title?: string;
onclick: () => void;
}
// Widget for displaying an item with icon, name, description, and action button(s)
// Used in lists of added columns, filters, etc.
export interface ListItemAttrs {
icon: string;
name: string;
description: string;
actions?: ListItemAction[];
onRemove?: () => void;
className?: string;
onclick?: (event: MouseEvent) => void;
}
export class ListItem implements m.ClassComponent<ListItemAttrs> {
view({attrs}: m.Vnode<ListItemAttrs>) {
const {icon, name, description, actions, onRemove, onclick} = attrs;
return m(
'.pf-exp-list-item',
{
className: attrs.className,
tabindex: 0,
role: 'listitem',
onclick,
onkeydown: (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// Trigger first action on Enter/Space
actions?.[0]?.onclick();
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (onRemove) {
e.preventDefault();
onRemove();
}
}
},
},
m(Icon, {icon}),
m(
'.pf-exp-list-item-info',
m('.pf-exp-list-item-name', name),
m('.pf-exp-list-item-description', description),
),
m('.pf-exp-list-item-actions', this.renderButtons(attrs)),
);
}
private renderButtons(attrs: ListItemAttrs): m.Children {
const buttons: m.Children = [];
// Render action buttons
if (attrs.actions) {
for (const action of attrs.actions) {
// Build button attributes based on what's available
const buttonAttrs: ButtonAttrs = action.label
? {
label: action.label,
icon: action.icon,
title: action.title,
variant: ButtonVariant.Outlined,
compact: true,
onclick: action.onclick,
}
: {
icon: action.icon ?? 'help',
title: action.title,
variant: ButtonVariant.Outlined,
compact: true,
onclick: action.onclick,
};
buttons.push(m(Button, buttonAttrs));
}
}
// Remove button
if (attrs.onRemove) {
buttons.push(
m(Button, {
icon: 'close',
compact: true,
onclick: attrs.onRemove,
title: 'Remove item',
}),
);
}
return buttons;
}
}
// Widget for inline label with any control (input, select, multiselect, etc.)
// The children are placed inside the label for proper accessibility
export interface LabeledControlAttrs {
label: string;
}
export class LabeledControl implements m.ClassComponent<LabeledControlAttrs> {
view({attrs, children}: m.CVnode<LabeledControlAttrs>) {
return m('label.pf-exp-labeled-control', m('span', attrs.label), children);
}
}
// Widget for a draggable list item with drag handle
export interface DraggableItemAttrs {
index: number;
onReorder: (fromIndex: number, toIndex: number) => void;
}
export class DraggableItem implements m.ClassComponent<DraggableItemAttrs> {
view({attrs, children}: m.CVnode<DraggableItemAttrs>) {
const {index, onReorder} = attrs;
return m(
'.pf-exp-draggable-item',
{
draggable: true,
ondragstart: (e: DragEvent) => {
e.dataTransfer?.setData('text/plain', index.toString());
},
ondragover: (e: DragEvent) => {
e.preventDefault();
},
ondrop: (e: DragEvent) => {
e.preventDefault();
const from = parseInt(
e.dataTransfer?.getData('text/plain') ?? '0',
10,
);
onReorder(from, index);
},
},
m('span.pf-exp-drag-handle', '☰'),
children,
);
}
}
// Widget for displaying a list of issues (errors or warnings) in a callout
// Used for validation errors, duplicate column warnings, etc.
export interface IssueListAttrs {
icon: 'error' | 'warning' | 'info';
title: string;
items: string[];
}
export class IssueList implements m.ClassComponent<IssueListAttrs> {
view({attrs}: m.Vnode<IssueListAttrs>) {
const {icon, title, items} = attrs;
if (items.length === 0) {
return null;
}
// Map icon to Intent for proper styling
const intentMap: Record<string, Intent> = {
error: Intent.Danger,
warning: Intent.Warning,
info: Intent.Primary,
};
const intent = intentMap[icon] ?? Intent.None;
return m(
Callout,
{icon, intent},
m('div', title),
m(
'ul.pf-exp-issue-list',
items.map((item) => m('li', item)),
),
);
}
}
// Widget for displaying a SQL table's description and columns
// Used in table source node info and join modal
export interface TableDescriptionAttrs {
table: SqlTable;
}
export class TableDescription
implements m.ClassComponent<TableDescriptionAttrs>
{
view({attrs}: m.Vnode<TableDescriptionAttrs>) {
const {table} = attrs;
return m(
'.pf-exp-table-description',
m(TextParagraph, {text: table.description}),
m(
'table.pf-table.pf-table-striped',
m(
'thead',
m('tr', m('th', 'Column'), m('th', 'Type'), m('th', 'Description')),
),
m(
'tbody',
table.columns.map((col) => {
return m(
'tr',
m('td', col.name),
m('td', perfettoSqlTypeToString(col.type)),
m('td', col.description),
);
}),
),
),
);
}
}
// Button for switching between basic and advanced modes
// Styled as a subtle, text-like button to indicate it's a secondary action
export interface AdvancedModeChangeButtonAttrs {
label: string;
icon?: string;
onclick: () => void;
}
export class AdvancedModeChangeButton
implements m.ClassComponent<AdvancedModeChangeButtonAttrs>
{
view({attrs}: m.Vnode<AdvancedModeChangeButtonAttrs>) {
const {label, icon, onclick} = attrs;
return m(Button, {
className: 'pf-exp-advanced-mode-button',
label,
icon,
onclick,
variant: ButtonVariant.Minimal,
});
}
}
// Widget for displaying informational text in a styled box
// Similar to the pattern used in timerange node for dynamic mode info
export class InfoBox implements m.ClassComponent {
view({children}: m.CVnode) {
return m('.pf-exp-info-box', children);
}
}
/**
* Automatically creates error/warning sections from node.context.issues
* Returns sections to prepend to the node's modify view
*/
export function createErrorSections(node: QueryNode): NodeModifySection[] {
const sections: NodeModifySection[] = [];
if (node.context.issues?.queryError) {
sections.push({
content: m(
Callout,
{icon: 'error'},
node.context.issues.queryError.message,
),
});
}
return sections;
}
/**
* Reusable list component with empty state and item rendering
* Used by nodes that display lists of items (filters, aggregations, columns, etc.)
*/
export interface ModifiableItemListAttrs<T> {
items: T[];
renderItem: (item: T, index: number) => m.Child;
emptyStateTitle: string;
emptyStateIcon?: string;
}
export function ModifiableItemList<T>(
attrs: ModifiableItemListAttrs<T>,
): m.Child {
if (attrs.items.length === 0) {
return m(EmptyState, {
title: attrs.emptyStateTitle,
icon: attrs.emptyStateIcon,
});
}
return m(
'.pf-modifiable-item-list',
attrs.items.map((item, index) => attrs.renderItem(item, index)),
);
}
// Widget for inline filter editing form
// Combines editing and creation into a single list item
export interface FormListItemAttrs<T> {
item: T;
isValid: boolean;
onUpdate: (updated: T) => void;
onRemove?: () => void; // Optional - if not provided, no remove button
children: m.Children;
}
export class FormListItem<T> implements m.ClassComponent<FormListItemAttrs<T>> {
view({attrs}: m.Vnode<FormListItemAttrs<T>>) {
const {isValid, onRemove, children} = attrs;
return m(
'.pf-exp-form-list-item',
{
className: isValid ? 'pf-valid' : 'pf-invalid',
},
m('.pf-exp-form-list-item-content', children),
// Only show remove button if onRemove is provided
onRemove &&
m(
'.pf-exp-form-list-item-actions',
m(Button, {
icon: 'close',
compact: true,
onclick: onRemove,
title: 'Remove item',
}),
),
);
}
}
// Material Design outlined input field with label on border
// Pass children as the third argument to m() for select options
export interface OutlinedFieldAttrs {
label: string;
value: string;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
disabled?: boolean;
placeholder?: string; // For text inputs
}
export class OutlinedField implements m.ClassComponent<OutlinedFieldAttrs> {
view({attrs, children}: m.Vnode<OutlinedFieldAttrs>) {
const {label, value, onchange, oninput, disabled, placeholder} = attrs;
// Determine if this is a select or input
// Children can be an array, so check if it has content
const isSelect =
children !== undefined &&
children !== null &&
(Array.isArray(children) ? children.length > 0 : true);
return m(
'fieldset.pf-outlined-field',
{
disabled,
},
[
m('legend.pf-outlined-field-legend', label),
isSelect
? m(
'select.pf-outlined-field-input',
{
value,
onchange,
disabled,
},
children,
)
: m('input.pf-outlined-field-input', {
type: 'text',
value,
oninput,
disabled,
placeholder,
}),
],
);
}
}
// Read-only version of OutlinedField for displaying static information
// Uses the same visual style but shows text instead of an input
export interface OutlinedFieldReadOnlyAttrs {
label: string;
value: string;
className?: string;
}
export class OutlinedFieldReadOnly
implements m.ClassComponent<OutlinedFieldReadOnlyAttrs>
{
view({attrs}: m.Vnode<OutlinedFieldReadOnlyAttrs>) {
const {label, value, className} = attrs;
return m(
'fieldset.pf-outlined-field',
{className},
m('legend.pf-outlined-field-legend', label),
m('.pf-outlined-field-input.pf-read-only', value),
);
}
}
// Wrapper around PopupMultiSelect with more obvious clickable styling
export interface OutlinedMultiSelectAttrs {
label: string;
options: MultiSelectOption[];
onChange: (diffs: MultiSelectDiff[]) => void;
showNumSelected?: boolean;
repeatCheckedItemsAtTop?: boolean;
compact?: boolean;
}
export class OutlinedMultiSelect
implements m.ClassComponent<OutlinedMultiSelectAttrs>
{
view({attrs}: m.Vnode<OutlinedMultiSelectAttrs>) {
return m('.pf-outlined-multiselect', m(PopupMultiSelect, attrs));
}
}
// Button styled like an OutlinedField placeholder for adding new items
// Matches the visual style of OutlinedField (border, height, etc.) but acts as a clickable button
export interface AddItemPlaceholderAttrs {
label: string;
icon?: string;
onclick?: () => void;
}
export class AddItemPlaceholder
implements m.ClassComponent<AddItemPlaceholderAttrs>
{
view({attrs}: m.Vnode<AddItemPlaceholderAttrs>) {
const {label, icon, onclick} = attrs;
return m(
'button.pf-add-item-placeholder',
{
type: 'button',
onclick,
},
[
icon && m(Icon, {icon}),
m('span.pf-add-item-placeholder__label', label),
],
);
}
}
// Generic inline editing list widget
// Renders a list of items with inline editing forms, validation, and an "add" button
export interface InlineEditListAttrs<T> {
items: T[]; // Can include partial/invalid items during editing
validate: (item: T) => boolean; // Returns true if item is valid
renderControls: (
item: T,
index: number,
onUpdate: (updated: T) => void,
) => m.Children; // Renders the form controls for editing an item
onUpdate: (items: T[]) => void; // Called when items array changes
onValidChange?: () => void; // Called when an item becomes valid (for triggering query rebuilds)
addButtonLabel: string;
addButtonIcon?: string;
emptyItem: () => T; // Factory function to create a new empty item
}
export class InlineEditList<T>
implements m.ClassComponent<InlineEditListAttrs<T>>
{
view({attrs}: m.Vnode<InlineEditListAttrs<T>>) {
const {
items,
validate,
renderControls,
onUpdate,
onValidChange,
addButtonLabel,
addButtonIcon,
emptyItem,
} = attrs;
const itemViews: m.Child[] = [];
// Render each item as an inline form
for (const [index, item] of items.entries()) {
const isValid = validate(item);
itemViews.push(
m(FormListItem<T>, {
item,
isValid,
onUpdate: (updated) => {
const wasValid = validate(item);
const nowValid = validate(updated);
// Update the item in the array
items[index] = updated;
onUpdate([...items]);
// If the item just became valid, trigger the valid change callback
if (!wasValid && nowValid && onValidChange) {
onValidChange();
}
},
onRemove: () => {
items.splice(index, 1);
onUpdate([...items]);
onValidChange?.();
},
children: renderControls(item, index, (updated) => {
const wasValid = validate(item);
const nowValid = validate(updated);
// Update the item
items[index] = updated;
onUpdate([...items]);
// If the item just became valid, trigger the valid change callback
if (!wasValid && nowValid && onValidChange) {
onValidChange();
}
}),
}),
);
}
// Add "Add item" button
itemViews.push(
m(AddItemPlaceholder, {
label: addButtonLabel,
icon: addButtonIcon,
onclick: () => {
items.push(emptyItem());
onUpdate([...items]);
},
}),
);
return m('.pf-inline-edit-list', itemViews);
}
}
// Widget for inline field with label on left and editable/read-only value on right
// Simpler alternative to InlineEditList for single-value fields
export interface InlineFieldAttrs {
label: string;
value: string;
icon?: string;
editable?: boolean; // If false, field is read-only (default: true)
placeholder?: string;
type?: string; // Input type (e.g., 'text', 'number')
onchange?: (value: string) => void;
validate?: (value: string) => boolean; // Returns true if valid
errorMessage?: string; // Tooltip message shown when validation fails
}
export class InlineField implements m.ClassComponent<InlineFieldAttrs> {
private inputValue: string = '';
private lastPropValue: string = '';
view({attrs}: m.Vnode<InlineFieldAttrs>) {
const {
label,
value,
icon,
editable = true,
placeholder,
type,
onchange,
validate,
errorMessage,
} = attrs;
// Only sync input value when the prop value actually changes (external update)
if (this.lastPropValue !== value) {
this.inputValue = value;
this.lastPropValue = value;
}
const isValid = validate ? validate(this.inputValue) : true;
return m(
'.pf-inline-field',
{
className: classNames(!isValid && 'pf-invalid'),
},
icon && m(Icon, {icon}),
m('span.pf-inline-field__label', label),
editable
? m(TextInput, {
value: this.inputValue,
placeholder,
type,
oninput: (e: Event) => {
const newValue = (e.target as HTMLInputElement).value;
this.inputValue = newValue;
// Always call onchange - validation is just for visual feedback
onchange?.(newValue);
},
})
: m('span.pf-inline-field__value', value),
!isValid &&
m(
Tooltip,
{
trigger: m(Icon, {icon: 'warning'}),
},
errorMessage ?? 'Invalid value',
),
);
}
}
/**
* Configuration for a warning modal.
*/
export interface WarningModalConfig {
readonly key: string;
readonly title: string;
readonly paragraphs: m.Children[];
readonly confirmText: string;
}
/**
* Shows a warning modal with customizable content.
* Returns a promise that resolves to true if user confirms, false if cancelled.
*/
export function showWarningModal(config: WarningModalConfig): Promise<boolean> {
return new Promise((resolve) => {
showModal({
key: config.key,
title: config.title,
icon: 'warning',
content: m(
'.pf-warning-modal',
config.paragraphs.map((p) => m('p', p)),
),
buttons: [
{
text: 'Cancel',
action: () => {
resolve(false);
},
},
{
text: config.confirmText,
primary: true,
action: () => {
resolve(true);
},
},
],
onClose: () => {
resolve(false);
},
});
});
}
/**
* Shows a confirmation modal warning the user that their current state will be lost.
* This modal is used when importing JSON or loading an example graph.
*
* @returns Promise that resolves to true if user confirms, false if cancelled
*/
export function showStateOverwriteWarning(): Promise<boolean> {
return showWarningModal({
key: 'state-overwrite-warning',
title: 'Warning: Current State Will Be Lost',
paragraphs: [
'This action will replace your current graph with a new one. All current nodes and connections will be lost.',
'Do you want to continue?',
],
confirmText: 'Continue',
});
}
/**
* Shows a warning modal before exporting to JSON.
* Warns users that the Data Explorer is in active development and
* future imports of the exported JSON are not guaranteed.
*
* @returns Promise that resolves to true if user confirms, false if cancelled
*/
export function showExportWarning(): Promise<boolean> {
return showWarningModal({
key: 'export-warning',
title: 'Experimental Feature Warning',
paragraphs: [
'The Data Explorer is still in active development. The JSON export format may change in future versions.',
m(
'strong',
'We do not guarantee that you will be able to import this JSON file in future versions of the Data Explorer.',
),
'Do you want to continue with the export?',
],
confirmText: 'Export Anyway',
});
}
// Resizable SQL editor with resize handle
// Provides consistent SQL editing experience across SQL source and join nodes
export interface ResizableSqlEditorAttrs {
sql: string;
onUpdate: (text: string) => void;
onExecute?: (text: string) => void;
autofocus?: boolean;
}
export class ResizableSqlEditor
implements m.ClassComponent<ResizableSqlEditorAttrs>
{
private editorHeight: number = 0;
private editorElement?: HTMLElement;
oncreate({dom}: m.VnodeDOM<ResizableSqlEditorAttrs>) {
this.editorElement = toHTMLElement(assertExists(findRef(dom, 'editor')));
this.editorElement.style.height = '400px';
}
view({attrs}: m.CVnode<ResizableSqlEditorAttrs>) {
return [
m(Editor, {
ref: 'editor',
text: attrs.sql,
onUpdate: attrs.onUpdate,
onExecute: attrs.onExecute,
autofocus: attrs.autofocus,
}),
m(ResizeHandle, {
onResize: (deltaPx: number) => {
this.editorHeight += deltaPx;
this.editorElement!.style.height = `${this.editorHeight}px`;
},
onResizeStart: () => {
this.editorHeight = this.editorElement!.clientHeight;
},
}),
];
}
}