ui: Created "Add chart" menu.

"Add Histogram" in the table viewer column header is now replaced with "Add chart".

This CL:
1. Adds an "Add chart" menu class component to display a list of charts options. Click handlers are passed down by the parent class.
2. Changes the "addHistogramTab" to be "addChartTab" so that any chart can be added to a tab.
3. Provides an enum for chart types and a function to convert between the enum and class component.

Change-Id: I0d8c6c9782c7a4b94ef97df045239b1728ed6317
diff --git a/ui/src/frontend/widgets/charts/add_chart_menu.ts b/ui/src/frontend/widgets/charts/add_chart_menu.ts
new file mode 100644
index 0000000..384ed9a
--- /dev/null
+++ b/ui/src/frontend/widgets/charts/add_chart_menu.ts
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 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 {MenuItem} from '../../../widgets/menu';
+import {Icons} from '../../../base/semantic_icons';
+import {ChartConfig, ChartOption, toTitleCase} from './chart';
+
+interface AddChartMenuItemAttrs {
+  readonly chartConfig: ChartConfig;
+  readonly chartOptions: Array<ChartOption>;
+  readonly addChart: (option: ChartOption, config: ChartConfig) => void;
+}
+
+export class AddChartMenuItem
+  implements m.ClassComponent<AddChartMenuItemAttrs>
+{
+  private renderAddChartOptions(
+    config: ChartConfig,
+    chartOptions: Array<ChartOption>,
+    addChart: (option: ChartOption, config: ChartConfig) => void,
+  ): m.Children {
+    return chartOptions.map((option) => {
+      return m(MenuItem, {
+        label: toTitleCase(option),
+        onclick: () => addChart(option, config),
+      });
+    });
+  }
+
+  view({attrs}: m.Vnode<AddChartMenuItemAttrs>) {
+    return m(
+      MenuItem,
+      {label: 'Add chart', icon: Icons.Chart},
+      this.renderAddChartOptions(
+        attrs.chartConfig,
+        attrs.chartOptions,
+        attrs.addChart,
+      ),
+    );
+  }
+}
diff --git a/ui/src/frontend/widgets/charts/chart.ts b/ui/src/frontend/widgets/charts/chart.ts
index 1a78c41..ccbf566 100644
--- a/ui/src/frontend/widgets/charts/chart.ts
+++ b/ui/src/frontend/widgets/charts/chart.ts
@@ -11,9 +11,11 @@
 // 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 {Row} from '../../../trace_processor/query_result';
 import {Engine} from '../../../trace_processor/engine';
-import {TableColumn, TableColumnSet} from '../sql/table/column';
+import {Filter, TableColumn, TableColumnSet} from '../sql/table/column';
+import {Histogram} from './histogram/histogram';
 
 export interface VegaLiteChartSpec {
   $schema: string;
@@ -41,6 +43,21 @@
   };
 }
 
+// Holds the various chart types and human readable string
+export enum ChartOption {
+  HISTOGRAM = 'histogram',
+}
+
+export interface ChartConfig {
+  readonly engine: Engine;
+  readonly columnTitle: string; // Human readable column name (ex: Duration)
+  readonly sqlColumn: string[]; // SQL column name (ex: dur)
+  readonly filters?: Filter[]; // Filters applied to SQL table
+  readonly tableDisplay?: string; // Human readable table name (ex: slices)
+  readonly query: string; // SQL query for the underlying data
+  readonly aggregationType?: 'nominal' | 'quantitative'; // Aggregation type.
+}
+
 export interface ChartData {
   readonly rows: Row[];
   readonly error?: string;
@@ -65,3 +82,14 @@
 
   return words.join(' ');
 }
+
+// renderChartComponent will take a chart option and config and map
+// to the corresponding chart class component.
+export function renderChartComponent(option: ChartOption, config: ChartConfig) {
+  switch (option) {
+    case ChartOption.HISTOGRAM:
+      return m(Histogram, config);
+    default:
+      return;
+  }
+}
diff --git a/ui/src/frontend/widgets/charts/chart_tab.ts b/ui/src/frontend/widgets/charts/chart_tab.ts
new file mode 100644
index 0000000..0dcc6d9
--- /dev/null
+++ b/ui/src/frontend/widgets/charts/chart_tab.ts
@@ -0,0 +1,65 @@
+// Copyright (C) 2024 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 {DetailsShell} from '../../../widgets/details_shell';
+import {filterTitle} from '../sql/table/column';
+import {addEphemeralTab} from '../../../common/add_ephemeral_tab';
+import {Tab} from '../../../public/tab';
+import {
+  ChartConfig,
+  ChartOption,
+  renderChartComponent,
+  toTitleCase,
+} from './chart';
+
+export function addChartTab(
+  chartOption: ChartOption,
+  chartConfig: ChartConfig,
+): void {
+  addEphemeralTab('histogramTab', new ChartTab(chartOption, chartConfig));
+}
+
+export class ChartTab implements Tab {
+  constructor(
+    private readonly chartOption: ChartOption,
+    private readonly chartConfig: ChartConfig,
+  ) {}
+
+  render() {
+    return m(
+      DetailsShell,
+      {
+        title: this.getTitle(),
+        description: this.getDescription(),
+      },
+      renderChartComponent(this.chartOption, this.chartConfig),
+    );
+  }
+
+  getTitle(): string {
+    return `${toTitleCase(this.chartConfig.columnTitle)} Histogram`;
+  }
+
+  private getDescription(): string {
+    let desc = `Count distribution for ${this.chartConfig.tableDisplay ?? ''} table`;
+
+    if (this.chartConfig.filters && this.chartConfig.filters.length > 0) {
+      desc += ' where ';
+      desc += this.chartConfig.filters.map((f) => filterTitle(f)).join(', ');
+    }
+
+    return desc;
+  }
+}
diff --git a/ui/src/frontend/widgets/charts/histogram/histogram.ts b/ui/src/frontend/widgets/charts/histogram/histogram.ts
index e4dd0d0..38f4a65 100644
--- a/ui/src/frontend/widgets/charts/histogram/histogram.ts
+++ b/ui/src/frontend/widgets/charts/histogram/histogram.ts
@@ -15,25 +15,14 @@
 import m from 'mithril';
 import {stringifyJsonWithBigints} from '../../../../base/json_utils';
 import {VegaView} from '../../../../widgets/vega_view';
-import {Filter} from '../../../widgets/sql/table/column';
 import {HistogramState} from './state';
 import {Spinner} from '../../../../widgets/spinner';
-import {Engine} from '../../../../trace_processor/engine';
+import {ChartConfig} from '../chart';
 
-export interface HistogramConfig {
-  engine: Engine;
-  columnTitle: string; // Human readable column name (ex: Duration)
-  sqlColumn: string[]; // SQL column name (ex: dur)
-  filters?: Filter[]; // Filters applied to SQL table
-  tableDisplay?: string; // Human readable table name (ex: slices)
-  query: string; // SQL query for the underlying data
-  aggregationType?: 'nominal' | 'quantitative'; // Aggregation type.
-}
-
-export class Histogram implements m.ClassComponent<HistogramConfig> {
+export class Histogram implements m.ClassComponent<ChartConfig> {
   private readonly state: HistogramState;
 
-  constructor({attrs}: m.Vnode<HistogramConfig>) {
+  constructor({attrs}: m.Vnode<ChartConfig>) {
     this.state = new HistogramState(
       attrs.engine,
       attrs.query,
diff --git a/ui/src/frontend/widgets/charts/histogram/tab.ts b/ui/src/frontend/widgets/charts/histogram/tab.ts
deleted file mode 100644
index 096245e..0000000
--- a/ui/src/frontend/widgets/charts/histogram/tab.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2024 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 {DetailsShell} from '../../../../widgets/details_shell';
-import {filterTitle} from '../../../widgets/sql/table/column';
-import {addEphemeralTab} from '../../../../common/add_ephemeral_tab';
-import {Tab} from '../../../../public/tab';
-import {Histogram, HistogramConfig} from './histogram';
-import {toTitleCase} from '../chart';
-
-export function addHistogramTab(config: HistogramConfig): void {
-  addEphemeralTab('histogramTab', new HistogramTab(config));
-}
-
-export class HistogramTab implements Tab {
-  constructor(private readonly config: HistogramConfig) {}
-
-  render() {
-    return m(
-      DetailsShell,
-      {
-        title: this.getTitle(),
-        description: this.getDescription(),
-      },
-      m(Histogram, this.config),
-    );
-  }
-
-  getTitle(): string {
-    return `${toTitleCase(this.config.columnTitle)} Histogram`;
-  }
-
-  private getDescription(): string {
-    let desc = `Count distribution for ${this.config.tableDisplay ?? ''} table`;
-
-    if (this.config.filters && this.config.filters.length > 0) {
-      desc += ' where ';
-      desc += this.config.filters.map((f) => filterTitle(f)).join(', ');
-    }
-
-    return desc;
-  }
-}
diff --git a/ui/src/frontend/widgets/sql/table/table.ts b/ui/src/frontend/widgets/sql/table/table.ts
index acd5ffe..28cc81c 100644
--- a/ui/src/frontend/widgets/sql/table/table.ts
+++ b/ui/src/frontend/widgets/sql/table/table.ts
@@ -40,9 +40,11 @@
 import {SqlTableState} from './state';
 import {SqlTableDescription} from './table_description';
 import {Intent} from '../../../../widgets/common';
-import {addHistogramTab} from '../../charts/histogram/tab';
+import {addChartTab} from '../../charts/chart_tab';
 import {Form} from '../../../../widgets/form';
 import {TextInput} from '../../../../widgets/text_input';
+import {AddChartMenuItem} from '../../charts/add_chart_menu';
+import {ChartConfig, ChartOption} from '../../charts/chart';
 
 export interface SqlTableConfig {
   readonly state: SqlTableState;
@@ -65,7 +67,7 @@
   return column.renderCell(sqlValue, getTableManager(state), additionalValues);
 }
 
-function columnTitle(column: TableColumn): string {
+export function columnTitle(column: TableColumn): string {
   if (column.getTitle !== undefined) {
     const title = column.getTitle();
     if (title !== undefined) return title;
@@ -283,6 +285,22 @@
           ? Icons.SortedDesc
           : Icons.ContextMenu;
 
+    const columnAlias =
+      this.state.getCurrentRequest().columns[
+        sqlColumnId(column.primaryColumn())
+      ];
+    const chartConfig: ChartConfig = {
+      engine: this.state.trace.engine,
+      columnTitle: columnTitle(column),
+      sqlColumn: [columnAlias],
+      filters: this.state.getFilters(),
+      tableDisplay: this.table.displayName ?? this.table.name,
+      query: this.state.getSqlQuery(
+        Object.fromEntries([[columnAlias, column.primaryColumn()]]),
+      ),
+      aggregationType: column.aggregation?.().dataType,
+    };
+
     return m(
       PopupMenu2,
       {
@@ -327,26 +345,10 @@
         {label: 'Add filter', icon: Icons.Filter},
         this.renderColumnFilterOptions(column),
       ),
-      m(MenuItem, {
-        label: 'Create histogram',
-        icon: Icons.Chart,
-        onclick: () => {
-          const columnAlias =
-            this.state.getCurrentRequest().columns[
-              sqlColumnId(column.primaryColumn())
-            ];
-          addHistogramTab({
-            engine: this.state.trace.engine,
-            sqlColumn: [columnAlias],
-            columnTitle: columnTitle(column),
-            filters: this.state.getFilters(),
-            tableDisplay: this.table.displayName ?? this.table.name,
-            query: this.state.getSqlQuery(
-              Object.fromEntries([[columnAlias, column.primaryColumn()]]),
-            ),
-            aggregationType: column.aggregation?.().dataType,
-          });
-        },
+      m(AddChartMenuItem, {
+        chartConfig,
+        chartOptions: [ChartOption.HISTOGRAM],
+        addChart: (option, config) => addChartTab(option, config),
       }),
       // Menu items before divider apply to selected column
       m(MenuDivider),