• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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