xref: /aosp_15_r20/external/pigweed/pw_web/log-viewer/src/components/log-view-controls/log-view-controls.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2024 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://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, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15import { LitElement, html } from 'lit';
16import {
17  customElement,
18  property,
19  query,
20  queryAll,
21  state,
22} from 'lit/decorators.js';
23import { styles } from './log-view-controls.styles';
24import { TableColumn } from '../../shared/interfaces';
25import { MdMenu } from '@material/web/menu/menu';
26
27/**
28 * A sub-component of the log view with user inputs for managing and customizing
29 * log entry display and interaction.
30 *
31 * @element log-view-controls
32 */
33@customElement('log-view-controls')
34export class LogViewControls extends LitElement {
35  static styles = styles;
36
37  /** The `id` of the parent view containing this log list. */
38  @property({ type: String })
39  viewId = '';
40
41  @property({ type: Array })
42  columnData: TableColumn[] = [];
43
44  /** Indicates whether to enable the button for closing the current log view. */
45  @property({ type: Boolean })
46  hideCloseButton = false;
47
48  /** The title of the parent log view, to be displayed on the log view toolbar */
49  @property()
50  viewTitle = '';
51
52  @property()
53  searchText = '';
54
55  @property()
56  lineWrap = true;
57
58  @property({ type: Boolean, reflect: true })
59  searchExpanded = false;
60
61  /**
62   * Flag to determine whether Shoelace components should be used by
63   * `LogViewControls`.
64   */
65  @property({ type: Boolean })
66  useShoelaceFeatures = true;
67
68  @state()
69  _colToggleMenuOpen = false;
70
71  @state()
72  _addlActionsMenuOpen = false;
73
74  @state()
75  _toolbarCollapsed = false;
76
77  @query('.search-field') _searchField!: HTMLInputElement;
78
79  @query('.col-toggle-button') _colToggleMenuButton!: HTMLElement;
80
81  @query('#col-toggle-menu') _colToggleMenu!: HTMLElement;
82
83  @query('.addl-actions-button') _addlActionsButton!: HTMLElement;
84
85  @query('.addl-actions-menu') _addlActionsMenu!: HTMLElement;
86
87  @queryAll('.item-checkbox') _itemCheckboxes!: HTMLCollection[];
88
89  /** The timer identifier for debouncing search input. */
90  private _inputDebounceTimer: number | null = null;
91
92  /** The delay (in ms) used for debouncing search input. */
93  private readonly INPUT_DEBOUNCE_DELAY = 50;
94
95  private resizeObserver = new ResizeObserver((entries) =>
96    this.handleResize(entries),
97  );
98
99  connectedCallback() {
100    super.connectedCallback();
101    this.resizeObserver.observe(this);
102  }
103
104  disconnectedCallback() {
105    super.disconnectedCallback();
106    this.resizeObserver.unobserve(this);
107  }
108
109  protected firstUpdated(): void {
110    this._searchField.dispatchEvent(new CustomEvent('input'));
111
112    // Supply anchor element to Material Web menus
113    if (this._addlActionsMenu) {
114      (this._addlActionsMenu as MdMenu).anchorElement = this._addlActionsButton;
115    }
116    if (this._colToggleMenu) {
117      (this._colToggleMenu as MdMenu).anchorElement = this._colToggleMenuButton;
118    }
119  }
120
121  /**
122   * Called whenever the search field value is changed. Debounces the input
123   * event and dispatches an event with the input value after a specified
124   * delay.
125   *
126   * @param {Event} event - The input event object.
127   */
128  private handleInput(event: Event) {
129    const inputElement =
130      (event.target as HTMLInputElement) ?? this._searchField;
131    const inputValue = inputElement.value;
132
133    // Update searchText immediately for responsiveness
134    this.searchText = inputValue;
135
136    // Debounce to avoid excessive updates and event dispatching
137    if (this._inputDebounceTimer) {
138      clearTimeout(this._inputDebounceTimer);
139    }
140
141    this._inputDebounceTimer = window.setTimeout(() => {
142      this.dispatchEvent(
143        new CustomEvent('input-change', {
144          detail: { viewId: this.viewId, inputValue: inputValue },
145          bubbles: true,
146          composed: true,
147        }),
148      );
149    }, this.INPUT_DEBOUNCE_DELAY);
150
151    this.markKeysInText(this._searchField);
152  }
153
154  private handleResize(entries: ResizeObserverEntry[]) {
155    for (const entry of entries) {
156      if (entry.contentRect.width < 800) {
157        this._toolbarCollapsed = true;
158      } else {
159        this.searchExpanded = false;
160        this._toolbarCollapsed = false;
161      }
162    }
163
164    this._colToggleMenuOpen = false;
165    this._addlActionsMenuOpen = false;
166  }
167
168  private markKeysInText(target: HTMLElement) {
169    const pattern = /\b(\w+):(?=\w)/;
170    const textContent = target.textContent || '';
171    const conditions = textContent.split(/\s+/);
172    const wordsBeforeColons: string[] = [];
173
174    for (const condition of conditions) {
175      const match = condition.match(pattern);
176      if (match) {
177        wordsBeforeColons.push(match[0]);
178      }
179    }
180  }
181
182  private handleKeydown = (event: KeyboardEvent) => {
183    if (event.key === 'Enter' || event.key === 'Cmd') {
184      event.preventDefault();
185    }
186  };
187
188  /**
189   * Dispatches a custom event for clearing logs. This event includes a
190   * `timestamp` object indicating the date/time in which the 'clear-logs' event
191   * was dispatched.
192   */
193  private handleClearLogsClick() {
194    const timestamp = new Date();
195
196    const clearLogs = new CustomEvent('clear-logs', {
197      detail: { timestamp },
198      bubbles: true,
199      composed: true,
200    });
201
202    this.dispatchEvent(clearLogs);
203  }
204
205  /** Dispatches a custom event for toggling wrapping. */
206  private handleWrapToggle() {
207    this.lineWrap = !this.lineWrap;
208    const wrapToggle = new CustomEvent('wrap-toggle', {
209      detail: { viewId: this.viewId, isChecked: this.lineWrap },
210      bubbles: true,
211      composed: true,
212    });
213
214    this.dispatchEvent(wrapToggle);
215  }
216
217  /**
218   * Dispatches a custom event for closing the parent view. This event includes
219   * a `viewId` object indicating the `id` of the parent log view.
220   */
221  private handleCloseViewClick() {
222    const closeView = new CustomEvent('close-view', {
223      bubbles: true,
224      composed: true,
225      detail: {
226        viewId: this.viewId,
227      },
228    });
229
230    this.dispatchEvent(closeView);
231  }
232
233  /**
234   * Dispatches a custom event for showing or hiding a column in the table. This
235   * event includes a `field` string indicating the affected column's field name
236   * and an `isChecked` boolean indicating whether to show or hide the column.
237   *
238   * @param {Event} event - The click event object.
239   */
240  private handleColumnToggle(event: Event) {
241    const target = event.currentTarget as HTMLElement;
242    if (target) {
243      const field = target.dataset.field as string;
244      const isChecked = target.hasAttribute('data-checked');
245
246      const columnToggle = new CustomEvent('column-toggle', {
247        bubbles: true,
248        composed: true,
249        detail: {
250          viewId: this.viewId,
251          field: field,
252          isChecked: isChecked,
253          columnData: this.columnData,
254        },
255      });
256
257      this.dispatchEvent(columnToggle);
258
259      if (isChecked) {
260        target.removeAttribute('data-checked');
261      } else {
262        target.setAttribute('data-checked', '');
263      }
264    }
265  }
266
267  private handleSplitRight() {
268    const splitView = new CustomEvent('split-view', {
269      detail: {
270        columnData: this.columnData,
271        viewTitle: this.viewTitle,
272        searchText: this.searchText,
273        orientation: 'horizontal',
274        parentId: this.viewId,
275      },
276      bubbles: true,
277      composed: true,
278    });
279
280    this.dispatchEvent(splitView);
281  }
282
283  private handleSplitDown() {
284    const splitView = new CustomEvent('split-view', {
285      detail: {
286        columnData: this.columnData,
287        viewTitle: this.viewTitle,
288        searchText: this.searchText,
289        orientation: 'vertical',
290        parentId: this.viewId,
291      },
292      bubbles: true,
293      composed: true,
294    });
295
296    this.dispatchEvent(splitView);
297  }
298
299  /**
300   * Dispatches a custom event for downloading a logs file. This event includes
301   * a `format` string indicating the format of the file to be downloaded and a
302   * `viewTitle` string which passes the title of the current view for naming
303   * the file.
304   *
305   * @param {Event} event - The click event object.
306   */
307  private handleDownloadLogs() {
308    const downloadLogs = new CustomEvent('download-logs', {
309      bubbles: true,
310      composed: true,
311      detail: {
312        format: 'plaintext',
313        viewTitle: this.viewTitle,
314      },
315    });
316
317    this.dispatchEvent(downloadLogs);
318  }
319
320  /** Opens and closes the column visibility dropdown menu. */
321  private toggleColumnVisibilityMenu() {
322    this._colToggleMenuOpen = !this._colToggleMenuOpen;
323  }
324
325  /** Opens and closes the additional actions dropdown menu. */
326  private toggleAddlActionsMenu() {
327    this._addlActionsMenuOpen = !this._addlActionsMenuOpen;
328  }
329
330  /** Opens and closes the search field while it is in a collapsible state. */
331  private toggleSearchField() {
332    this.searchExpanded = !this.searchExpanded;
333  }
334
335  private handleClearSearchClick() {
336    this._searchField.value = '';
337
338    const event = new Event('input', {
339      bubbles: true,
340      cancelable: true,
341    });
342    this.handleInput(event);
343
344    this.searchExpanded = false;
345  }
346
347  render() {
348    return html`
349      <p class="host-name">${this.viewTitle}</p>
350
351      <div class="toolbar" role="toolbar">
352        <md-filled-text-field
353          class="search-field"
354          placeholder="Filter logs"
355          ?hidden=${this._toolbarCollapsed && !this.searchExpanded}
356          .value="${this.searchText}"
357          @input="${this.handleInput}"
358          @keydown="${this.handleKeydown}"
359        >
360          <div class="field-buttons" slot="trailing-icon">
361            <md-icon-button
362              @click=${this.handleClearSearchClick}
363              ?hidden=${this._searchField?.value === '' && !this.searchExpanded}
364              title="Clear filter query"
365            >
366              <md-icon>&#xe888;</md-icon>
367            </md-icon-button>
368            <md-icon-button
369              href="https://pigweed.dev/pw_web/log_viewer.html#filter-logs"
370              target="_blank"
371              title="Go to the log filter documentation page"
372              aria-label="Go to the log filter documentation page"
373            >
374              <md-icon>&#xe8fd;</md-icon>
375            </md-icon-button>
376          </div>
377        </md-filled-text-field>
378
379        <div class="actions-container">
380          <span class="action-button" ?hidden=${!this._toolbarCollapsed}>
381            <md-icon-button
382              @click=${this.toggleSearchField}
383              ?toggle=${this.searchExpanded}
384              ?selected=${this.searchExpanded}
385              ?hidden=${this.searchExpanded}
386              title="Toggle search field"
387            >
388              <md-icon>&#xe8b6;</md-icon>
389              <md-icon slot="selected">&#xea76;</md-icon>
390            </md-icon-button>
391          </span>
392
393          <span class="action-button" ?hidden=${this._toolbarCollapsed}>
394            <md-icon-button
395              @click=${this.handleClearLogsClick}
396              title="Clear logs"
397              aria-label="Clear logs"
398            >
399              <md-icon>&#xe16c;</md-icon>
400            </md-icon-button>
401          </span>
402
403          <span class="action-button" ?hidden=${this._toolbarCollapsed}>
404            <md-icon-button
405              @click=${this.handleWrapToggle}
406              toggle=${this.lineWrap}
407              ?selected=${this.lineWrap}
408              title="Toggle line wrapping"
409              aria-label="Toggle line wrapping"
410            >
411              <md-icon>&#xe25b;</md-icon>
412            </md-icon-button>
413          </span>
414
415          <span class="action-button" ?hidden=${this._toolbarCollapsed}>
416            <md-icon-button
417              @click=${this.toggleColumnVisibilityMenu}
418              class="col-toggle-button"
419              title="Toggle columns"
420              aria-label="Toggle columns"
421            >
422              <md-icon>&#xe8ec;</md-icon>
423            </md-icon-button>
424
425            <md-menu
426              quick
427              id="col-toggle-menu"
428              class="col-toggle-menu"
429              positioning="popover"
430              ?open=${this._colToggleMenuOpen}
431              @closed=${() => {
432                this._colToggleMenuOpen = false;
433              }}
434            >
435              ${this.columnData.map(
436                (column) => html`
437                  <md-menu-item
438                    type="button"
439                    keep-open="true"
440                    @click=${this.handleColumnToggle}
441                    data-field=${column.fieldName}
442                    ?data-checked=${column.isVisible}
443                  >
444                    <label>
445                      <md-checkbox
446                        class="item-checkbox"
447                        id=${column.fieldName}
448                        data-field=${column.fieldName}
449                        ?checked=${column.isVisible}
450                        tabindex="-1"
451                      ></md-checkbox>
452                      ${column.fieldName}</label
453                    >
454                  </md-menu-item>
455                `,
456              )}
457            </md-menu>
458          </span>
459
460          <span class="action-button">
461            <md-icon-button
462              @click=${this.toggleAddlActionsMenu}
463              class="addl-actions-button"
464              title="Open additional actions menu"
465              aria-label="Open additional actions menu"
466            >
467              <md-icon>&#xe5d4;</md-icon>
468            </md-icon-button>
469
470            <md-menu
471              quick
472              has-overflow
473              class="addl-actions-menu"
474              positioning="popover"
475              ?open=${this._addlActionsMenuOpen}
476              @closed=${() => {
477                this._addlActionsMenuOpen = false;
478              }}
479            >
480              <md-sub-menu
481                quick
482                id="col-toggle-sub-menu"
483                class="col-toggle-menu"
484                positioning="popover"
485                ?open=${this._colToggleMenuOpen}
486                @closed=${() => {
487                  this._colToggleMenuOpen = false;
488                }}
489              >
490                <md-menu-item
491                  slot="item"
492                  type="button"
493                  title="Toggle columns"
494                  ?hidden=${!this._toolbarCollapsed}
495                >
496                  <md-icon slot="start" data-variant="icon">&#xe8ec;</md-icon>
497                  <div slot="headline">Toggle columns</div>
498                  <md-icon slot="end" data-variant="icon">&#xe5df;</md-icon>
499                </md-menu-item>
500
501                <md-menu slot="menu" positioning="popover">
502                  ${this.columnData.map(
503                    (column) => html`
504                      <md-menu-item
505                        type="button"
506                        keep-open="true"
507                        @click=${this.handleColumnToggle}
508                        data-field=${column.fieldName}
509                        ?data-checked=${column.isVisible}
510                      >
511                        <label>
512                          <md-checkbox
513                            class="item-checkbox"
514                            id=${column.fieldName}
515                            data-field=${column.fieldName}
516                            ?checked=${column.isVisible}
517                            tabindex="-1"
518                          ></md-checkbox>
519                          ${column.fieldName}</label
520                        >
521                      </md-menu-item>
522                    `,
523                  )}
524                </md-menu>
525              </md-sub-menu>
526
527              <md-menu-item
528                @click=${this.handleWrapToggle}
529                type="button"
530                title="Toggle line wrapping"
531                ?hidden=${!this._toolbarCollapsed}
532              >
533                <md-icon slot="start" data-variant="icon">&#xe25b;</md-icon>
534                <div slot="headline">Toggle line wrapping</div>
535              </md-menu-item>
536
537              <md-menu-item
538                @click=${this.handleSplitRight}
539                type="button"
540                title="Open a new view to the right of the current view"
541                ?hidden=${!this.useShoelaceFeatures}
542              >
543                <md-icon slot="start" data-variant="icon">&#xf674;</md-icon>
544                <div slot="headline">Split right</div>
545              </md-menu-item>
546
547              <md-menu-item
548                @click=${this.handleSplitDown}
549                type="button"
550                title="Open a new view below the current view"
551                ?hidden=${!this.useShoelaceFeatures}
552              >
553                <md-icon slot="start" data-variant="icon">&#xf676;</md-icon>
554                <div slot="headline">Split down</div>
555              </md-menu-item>
556
557              <md-menu-item
558                @click=${this.handleDownloadLogs}
559                type="button"
560                title="Download current logs as a plaintext file"
561              >
562                <md-icon slot="start" data-variant="icon">&#xf090;</md-icon>
563                <div slot="headline">Download logs (.txt)</div>
564              </md-menu-item>
565
566              <md-menu-item
567                @click=${this.handleClearLogsClick}
568                type="button"
569                title="Clear logs"
570                ?hidden=${!this._toolbarCollapsed}
571              >
572                <md-icon slot="start" data-variant="icon">&#xe16c;</md-icon>
573                <div slot="headline">Clear logs</div>
574              </md-menu-item>
575            </md-menu>
576          </span>
577
578          <span class="action-button">
579            <md-icon-button
580              ?hidden=${this.hideCloseButton}
581              @click=${this.handleCloseViewClick}
582              title="Close view"
583            >
584              <md-icon>&#xe5cd;</md-icon>
585            </md-icon-button>
586          </span>
587
588          <span class="action-button" hidden>
589            <md-icon-button>
590              <md-icon>&#xe5d3;</md-icon>
591            </md-icon-button>
592          </span>
593        </div>
594      </div>
595    `;
596  }
597}
598