• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright 2023 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';
25
26/**
27 * A sub-component of the log view with user inputs for managing and customizing
28 * log entry display and interaction.
29 *
30 * @element log-view-controls
31 */
32@customElement('log-view-controls')
33export class LogViewControls extends LitElement {
34  static styles = styles;
35
36  /** The `id` of the parent view containing this log list. */
37  @property({ type: String })
38  viewId = '';
39
40  @property({ type: Array })
41  columnData: TableColumn[] = [];
42
43  /** Indicates whether to enable the button for closing the current log view. */
44  @property({ type: Boolean })
45  hideCloseButton = false;
46
47  /** The title of the parent log view, to be displayed on the log view toolbar */
48  @property()
49  viewTitle = '';
50
51  @property()
52  searchText = '';
53
54  @state()
55  _moreActionsMenuOpen = false;
56
57  @query('.field-menu') _fieldMenu!: HTMLMenuElement;
58
59  @query('#search-field') _searchField!: HTMLInputElement;
60
61  @queryAll('.item-checkboxes') _itemCheckboxes!: HTMLCollection[];
62
63  /** The timer identifier for debouncing search input. */
64  private _inputDebounceTimer: number | null = null;
65
66  /** The delay (in ms) used for debouncing search input. */
67  private readonly INPUT_DEBOUNCE_DELAY = 50;
68
69  @query('.more-actions-button') moreActionsButtonEl!: HTMLElement;
70
71  constructor() {
72    super();
73  }
74
75  protected firstUpdated(): void {
76    this._searchField.dispatchEvent(new CustomEvent('input'));
77  }
78
79  /**
80   * Called whenever the search field value is changed. Debounces the input
81   * event and dispatches an event with the input value after a specified
82   * delay.
83   *
84   * @param {Event} event - The input event object.
85   */
86  private handleInput(event: Event) {
87    const inputElement = event.target as HTMLInputElement;
88    const inputValue = inputElement.value;
89
90    // Update searchText immediately for responsiveness
91    this.searchText = inputValue;
92
93    // Debounce to avoid excessive updates and event dispatching
94    if (this._inputDebounceTimer) {
95      clearTimeout(this._inputDebounceTimer);
96    }
97
98    this._inputDebounceTimer = window.setTimeout(() => {
99      this.dispatchEvent(
100        new CustomEvent('input-change', {
101          detail: { viewId: this.viewId, inputValue: inputValue },
102          bubbles: true,
103          composed: true,
104        }),
105      );
106    }, this.INPUT_DEBOUNCE_DELAY);
107
108    this.markKeysInText(this._searchField);
109  }
110
111  private markKeysInText(target: HTMLElement) {
112    const pattern = /\b(\w+):(?=\w)/;
113    const textContent = target.textContent || '';
114    const conditions = textContent.split(/\s+/);
115    const wordsBeforeColons: string[] = [];
116
117    for (const condition of conditions) {
118      const match = condition.match(pattern);
119      if (match) {
120        wordsBeforeColons.push(match[0]);
121      }
122    }
123  }
124
125  private handleKeydown = (event: KeyboardEvent) => {
126    if (event.key === 'Enter' || event.key === 'Cmd') {
127      event.preventDefault();
128    }
129  };
130
131  /**
132   * Dispatches a custom event for clearing logs. This event includes a
133   * `timestamp` object indicating the date/time in which the 'clear-logs' event
134   * was dispatched.
135   */
136  private handleClearLogsClick() {
137    const timestamp = new Date();
138
139    const clearLogs = new CustomEvent('clear-logs', {
140      detail: { timestamp },
141      bubbles: true,
142      composed: true,
143    });
144
145    this.dispatchEvent(clearLogs);
146  }
147
148  /** Dispatches a custom event for toggling wrapping. */
149  private handleWrapToggle() {
150    const wrapToggle = new CustomEvent('wrap-toggle', {
151      bubbles: true,
152      composed: true,
153    });
154
155    this.dispatchEvent(wrapToggle);
156  }
157
158  /**
159   * Dispatches a custom event for closing the parent view. This event includes
160   * a `viewId` object indicating the `id` of the parent log view.
161   */
162  private handleCloseViewClick() {
163    const closeView = new CustomEvent('close-view', {
164      bubbles: true,
165      composed: true,
166      detail: {
167        viewId: this.viewId,
168      },
169    });
170
171    this.dispatchEvent(closeView);
172  }
173
174  /**
175   * Dispatches a custom event for showing or hiding a column in the table. This
176   * event includes a `field` string indicating the affected column's field name
177   * and an `isChecked` boolean indicating whether to show or hide the column.
178   *
179   * @param {Event} event - The click event object.
180   */
181  private handleColumnToggle(event: Event) {
182    const inputEl = event.target as HTMLInputElement;
183    const columnToggle = new CustomEvent('column-toggle', {
184      bubbles: true,
185      composed: true,
186      detail: {
187        viewId: this.viewId,
188        field: inputEl.value,
189        isChecked: inputEl.checked,
190      },
191    });
192
193    this.dispatchEvent(columnToggle);
194  }
195
196  private handleSplitRight() {
197    const splitView = new CustomEvent('split-view', {
198      detail: {
199        columnData: this.columnData,
200        viewTitle: this.viewTitle,
201        searchText: this.searchText,
202        orientation: 'horizontal',
203        parentId: this.viewId,
204      },
205      bubbles: true,
206      composed: true,
207    });
208
209    this.dispatchEvent(splitView);
210  }
211
212  private handleSplitDown() {
213    const splitView = new CustomEvent('split-view', {
214      detail: {
215        columnData: this.columnData,
216        viewTitle: this.viewTitle,
217        searchText: this.searchText,
218        orientation: 'vertical',
219        parentId: this.viewId,
220      },
221      bubbles: true,
222      composed: true,
223    });
224
225    this.dispatchEvent(splitView);
226  }
227
228  /**
229   * Dispatches a custom event for downloading a logs file. This event includes
230   * a `format` string indicating the format of the file to be downloaded and a
231   * `viewTitle` string which passes the title of the current view for naming
232   * the file.
233   *
234   * @param {Event} event - The click event object.
235   */
236  private handleDownloadLogs() {
237    const downloadLogs = new CustomEvent('download-logs', {
238      bubbles: true,
239      composed: true,
240      detail: {
241        format: 'plaintext',
242        viewTitle: this.viewTitle,
243      },
244    });
245
246    this.dispatchEvent(downloadLogs);
247  }
248
249  /** Opens and closes the column visibility dropdown menu. */
250  private toggleColumnVisibilityMenu() {
251    this._fieldMenu.hidden = !this._fieldMenu.hidden;
252  }
253
254  /** Opens and closes the More Actions menu. */
255  private toggleMoreActionsMenu() {
256    this._moreActionsMenuOpen = !this._moreActionsMenuOpen;
257  }
258
259  render() {
260    return html`
261      <p class="host-name">${this.viewTitle}</p>
262
263      <div class="input-container">
264        <input
265          id="search-field"
266          type="text"
267          .value="${this.searchText}"
268          @input="${this.handleInput}"
269          @keydown="${this.handleKeydown}"
270        />
271      </div>
272
273      <div class="actions-container">
274        <span class="action-button" title="Clear logs">
275          <md-icon-button @click=${this.handleClearLogsClick}>
276            <md-icon>&#xe16c;</md-icon>
277          </md-icon-button>
278        </span>
279
280        <span class="action-button" title="Toggle line wrapping">
281          <md-icon-button @click=${this.handleWrapToggle} toggle>
282            <md-icon>&#xe25b;</md-icon>
283          </md-icon-button>
284        </span>
285
286        <span class="action-button field-toggle" title="Toggle columns">
287          <md-icon-button @click=${this.toggleColumnVisibilityMenu} toggle>
288            <md-icon>&#xe8ec;</md-icon>
289          </md-icon-button>
290          <menu class="field-menu" hidden>
291            ${this.columnData.map(
292              (column) => html`
293                <li class="field-menu-item">
294                  <input
295                    class="item-checkboxes"
296                    @click=${this.handleColumnToggle}
297                    ?checked=${column.isVisible}
298                    type="checkbox"
299                    value=${column.fieldName}
300                    id=${column.fieldName}
301                  />
302                  <label for=${column.fieldName}>${column.fieldName}</label>
303                </li>
304              `,
305            )}
306          </menu>
307        </span>
308
309        <span class="action-button" title="Additional actions">
310          <md-icon-button
311            @click=${this.toggleMoreActionsMenu}
312            class="more-actions-button"
313          >
314            <md-icon>&#xe5d4;</md-icon>
315          </md-icon-button>
316
317          <md-menu
318            quick
319            fixed
320            ?open=${this._moreActionsMenuOpen}
321            .anchor=${this.moreActionsButtonEl}
322            @closed=${() => {
323              this._moreActionsMenuOpen = false;
324            }}
325          >
326            <md-menu-item
327              headline="Split Right"
328              @click=${this.handleSplitRight}
329              role="button"
330              title="Open a new view to the right of the current view"
331            >
332              <md-icon slot="start" data-variant="icon">&#xf674;</md-icon>
333            </md-menu-item>
334
335            <md-menu-item
336              headline="Split Down"
337              @click=${this.handleSplitDown}
338              role="button"
339              title="Open a new view below the current view"
340            >
341              <md-icon slot="start" data-variant="icon">&#xf676;</md-icon>
342            </md-menu-item>
343
344            <md-menu-item
345              headline="Download logs (.txt)"
346              @click=${this.handleDownloadLogs}
347              role="button"
348              title="Download current logs as a plaintext file"
349            >
350              <md-icon slot="start" data-variant="icon">&#xf090;</md-icon>
351            </md-menu-item>
352          </md-menu>
353        </span>
354
355        <span
356          class="action-button"
357          title="Close view"
358          ?hidden=${this.hideCloseButton}
359        >
360          <md-icon-button @click=${this.handleCloseViewClick}>
361            <md-icon>close</md-icon>
362          </md-icon-button>
363        </span>
364
365        <span class="action-button" hidden>
366          <md-icon-button>
367            <md-icon>&#xe5d3;</md-icon>
368          </md-icon-button>
369        </span>
370      </div>
371    `;
372  }
373}
374