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></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></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></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></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"></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"></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"></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></md-icon> 368 </md-icon-button> 369 </span> 370 </div> 371 `; 372 } 373} 374