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></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></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></md-icon> 389 <md-icon slot="selected"></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></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></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></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></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"></md-icon> 497 <div slot="headline">Toggle columns</div> 498 <md-icon slot="end" data-variant="icon"></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"></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"></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"></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"></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"></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></md-icon> 585 </md-icon-button> 586 </span> 587 588 <span class="action-button" hidden> 589 <md-icon-button> 590 <md-icon></md-icon> 591 </md-icon-button> 592 </span> 593 </div> 594 </div> 595 `; 596 } 597} 598