1// Copyright (C) 2025 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {PivotTableState} from './pivot_table_state'; 17import {Spinner} from '../../../../widgets/spinner'; 18import {PivotTreeNode} from './pivot_tree_node'; 19import {Button} from '../../../../widgets/button'; 20import {Icons} from '../../../../base/semantic_icons'; 21import {TableColumn, tableColumnId} from '../table/table_column'; 22import {MenuDivider, MenuItem, PopupMenu} from '../../../../widgets/menu'; 23import {Anchor} from '../../../../widgets/anchor'; 24import {renderColumnIcon, renderSortMenuItems} from '../table/table_header'; 25import {SelectColumnMenu} from '../table/select_column_menu'; 26import {SqlColumn} from '../table/sql_column'; 27import {buildSqlQuery} from '../table/query_builder'; 28import {Aggregation, AGGREGATIONS} from './aggregations'; 29import {aggregationId, pivotId} from './ids'; 30import { 31 ColumnDescriptor, 32 CustomTable, 33 ReorderableColumns, 34} from '../../../../widgets/custom_table'; 35 36export interface PivotTableAttrs { 37 readonly state: PivotTableState; 38 // Additional button to render at the end of each row. Typically used 39 // for adding new filters. 40 extraRowButton?(node: PivotTreeNode): m.Children; 41} 42 43export class PivotTable implements m.ClassComponent<PivotTableAttrs> { 44 view({attrs}: m.CVnode<PivotTableAttrs>) { 45 const state = attrs.state; 46 const data = state.getData(); 47 const pivotColumns: ColumnDescriptor<PivotTreeNode>[] = state 48 .getPivots() 49 .map((pivot, index) => ({ 50 title: this.renderPivotColumnHeader(attrs, pivot, index), 51 render: (node) => { 52 if (node.isRoot()) { 53 return { 54 cell: 'Total values:', 55 className: 'total-values', 56 colspan: state.getPivots().length, 57 }; 58 } 59 const status = node.getPivotDisplayStatus(index); 60 const value = node.getPivotValue(index); 61 return { 62 cell: [ 63 (status === 'collapsed' || status === 'expanded') && 64 m(Button, { 65 icon: 66 status === 'collapsed' ? Icons.ExpandDown : Icons.ExpandUp, 67 onclick: () => (node.collapsed = !node.collapsed), 68 }), 69 // Show a non-clickable indicator that the value is auto-expanded. 70 status === 'auto_expanded' && 71 m(Button, { 72 icon: 'chevron_right', 73 disabled: true, 74 }), 75 // Indent the expanded values to align them with the parent value 76 // even though they do not have the "expand/collapse" button. 77 status === 'pivoted_value' && m('span.indent'), 78 value !== undefined && state.getPivots()[index].renderCell(value), 79 // Show ellipsis for the last pivot if the node is collapsed to 80 // make it clear to the user that there are some values. 81 status === 'hidden_behind_collapsed' && '...', 82 ], 83 }; 84 }, 85 })); 86 87 const aggregationColumns: ColumnDescriptor<PivotTreeNode>[] = state 88 .getAggregations() 89 .map((agg, index) => ({ 90 title: this.renderAggregationColumnHeader(attrs, agg, index), 91 render: (node) => ({ 92 cell: agg.column.renderCell(node.getAggregationValue(index)), 93 }), 94 })); 95 96 const extraRowButton = attrs.extraRowButton; 97 const extraButtonColumn: ReorderableColumns<PivotTreeNode> | undefined = 98 extraRowButton && { 99 columns: [ 100 { 101 title: undefined, 102 render: (node) => ({ 103 cell: extraRowButton(node), 104 className: 'action-button', 105 }), 106 }, 107 ], 108 hasLeftBorder: false, 109 }; 110 111 // Expand the tree to a list of rows to show. 112 const nodes: PivotTreeNode[] = data ? [...data.listDescendants()] : []; 113 114 return [ 115 m(CustomTable<PivotTreeNode>, { 116 className: 'pivot-table', 117 data: nodes, 118 columns: [ 119 { 120 columns: pivotColumns, 121 reorder: (from, to) => state.movePivot(from, to), 122 }, 123 { 124 columns: aggregationColumns, 125 reorder: (from, to) => state.moveAggregation(from, to), 126 }, 127 extraButtonColumn, 128 ], 129 }), 130 data === undefined && m(Spinner), 131 ]; 132 } 133 134 renderPivotColumnHeader( 135 attrs: PivotTableAttrs, 136 pivot: TableColumn, 137 index: number, 138 ) { 139 const state = attrs.state; 140 const sorted = state.isSortedByPivot(pivot); 141 return m( 142 PopupMenu, 143 { 144 trigger: m(Anchor, {icon: renderColumnIcon(sorted)}, pivotId(pivot)), 145 }, 146 [ 147 // Sort by pivot. 148 renderSortMenuItems(sorted, (direction) => 149 state.sortByPivot(pivot, direction), 150 ), 151 // Remove pivot: show only if there is more than one pivot (to avoid 152 // removing the last pivot). 153 state.getPivots().length > 1 && 154 m(MenuItem, { 155 label: 'Remove', 156 icon: Icons.Delete, 157 onclick: () => state.removePivot(index), 158 }), 159 160 // End of "per-pivot" menu items. The following menu items are table-level 161 // operations (i.e. "add pivot"). 162 m(MenuDivider), 163 164 m( 165 MenuItem, 166 { 167 label: 'Add pivot', 168 icon: Icons.Add, 169 }, 170 m(SelectColumnMenu, { 171 columns: state.table.columns.map((column) => ({ 172 key: tableColumnId(column), 173 column, 174 })), 175 manager: { 176 filters: state.filters, 177 trace: state.trace, 178 getSqlQuery: (columns: {[key: string]: SqlColumn}) => 179 buildSqlQuery({ 180 table: state.table.name, 181 columns, 182 filters: state.filters.get(), 183 }), 184 }, 185 existingColumnIds: new Set(state.getPivots().map(pivotId)), 186 onColumnSelected: (column) => state.addPivot(column, index), 187 }), 188 ), 189 ], 190 ); 191 } 192 193 renderAggregationColumnHeader( 194 attrs: PivotTableAttrs, 195 agg: Aggregation, 196 index: number, 197 ) { 198 const state = attrs.state; 199 const sorted = state.isSortedByAggregation(agg); 200 return m( 201 PopupMenu, 202 { 203 trigger: m( 204 Anchor, 205 {icon: renderColumnIcon(sorted)}, 206 aggregationId(agg), 207 ), 208 }, 209 [ 210 // Sort by aggregation. 211 renderSortMenuItems(sorted, (direction) => 212 state.sortByAggregation(agg, direction), 213 ), 214 // Remove aggregation. 215 // Do not remove count aggregation to ensure that there is always at least one aggregation. 216 agg.op !== 'count' && 217 m(MenuItem, { 218 label: 'Remove', 219 icon: Icons.Delete, 220 onclick: () => state.removeAggregation(index), 221 }), 222 // Change aggregation operation. 223 // Do not change aggregation for count (as it's the only one which doesn't require a column). 224 agg.op !== 'count' && 225 m( 226 MenuItem, 227 { 228 label: 'Change aggregation', 229 icon: Icons.Change, 230 }, 231 AGGREGATIONS.filter((a) => a !== agg.op).map((a) => 232 m(MenuItem, { 233 label: a, 234 onclick: () => 235 state.replaceAggregation(index, { 236 op: a, 237 column: agg.column, 238 }), 239 }), 240 ), 241 ), 242 // Add the same aggregation again. 243 // Designed to be used together with "change aggregation" to allow the user to add multiple 244 // aggregations on the same column (e.g. MIN / MAX). 245 m(MenuItem, { 246 label: 'Duplicate', 247 icon: Icons.Copy, 248 onclick: () => state.addAggregation(agg, index + 1), 249 }), 250 251 // End of "per-pivot" menu items. The following menu items are table-level 252 // operations (i.e. "add pivot"). 253 m(MenuDivider), 254 255 m( 256 MenuItem, 257 { 258 label: 'Add aggregation', 259 icon: Icons.Add, 260 }, 261 m(SelectColumnMenu, { 262 columns: state.table.columns.map((column) => ({ 263 key: tableColumnId(column), 264 column, 265 })), 266 manager: { 267 filters: state.filters, 268 trace: state.trace, 269 getSqlQuery: (columns: {[key: string]: SqlColumn}) => 270 buildSqlQuery({ 271 table: state.table.name, 272 columns, 273 filters: state.filters.get(), 274 }), 275 }, 276 columnMenu: (column) => ({ 277 rightIcon: '', 278 children: AGGREGATIONS.map((agg) => 279 m(MenuItem, { 280 label: agg, 281 onclick: () => state.addAggregation({op: agg, column}, index), 282 }), 283 ), 284 }), 285 }), 286 ), 287 ], 288 ); 289 } 290} 291