[logging] Make LogViewActions lit

This component is now backed by a lit element.

Additional integration tests for the state updates and interactions
with other components through events have been added.

Bug: 100046

Change-Id: Ie5998c4e413482b956ab110a3680bb9a93f854f7
Reviewed-on: https://fuchsia-review.googlesource.com/c/vscode-plugins/+/729642
Reviewed-by: Solly Ross <sollyross@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/media/OWNERS b/media/OWNERS
new file mode 100644
index 0000000..2c82e23
--- /dev/null
+++ b/media/OWNERS
@@ -0,0 +1 @@
+miguelfrde@google.com
diff --git a/media/webview-logging.css b/media/webview-logging.css
index 63ad059..16293fc 100644
--- a/media/webview-logging.css
+++ b/media/webview-logging.css
@@ -2,8 +2,6 @@
  * Use of this source code is governed by a BSD-style license that can be
  * found in the LICENSE file. */
 
-@import '@vscode/codicons/dist/codicon.css';
-
 :root {
   --log-list-buffer: calc(100vh - 50px);
   --log-control-height: 24px;
@@ -80,29 +78,6 @@
   width: 100%;
 }
 
-.action-icon {
-  background: none;
-  border: none;
-  color: var(--vscode-icon-foreground);
-  height: var(--log-action-height);
-  padding: var(--input-padding-vertical) var(--input-padding-horizontal);
-  text-align: center;
-}
-
-.action-icon:hover {
-  background: var(--vscode-toolbar-hoverBackground);
-  border-radius: 5px;
-  cursor: pointer;
-}
-
-#wrap-logs[wrap-active] {
-  color: var(--vscode-icon-foreground);
-}
-
-#wrap-logs:not([wrap-active]) {
-  color: var(--vscode-disabledForeground);
-}
-
 .column-resize {
   cursor: col-resize;
   height: 100%;
diff --git a/webviews/logging/components/base_element.ts b/webviews/logging/components/base_element.ts
index 9a773c2..aea49ca 100644
--- a/webviews/logging/components/base_element.ts
+++ b/webviews/logging/components/base_element.ts
@@ -8,8 +8,14 @@
 import vsCodeStyle from '../../../media/vscode.css';
 // @ts-ignore
 import resetStyle from '../../../media/reset.css';
+// @ts-ignore
+import codiconStyle from '../../../node_modules/@vscode/codicons/dist/codicon.css';
 
 @customElement('base-element')
 export class BaseElement extends LitElement {
-  static styles = css`${unsafeCSS(vsCodeStyle)} ${unsafeCSS(resetStyle)}` as CSSResultGroup;
+  static styles = css`
+    ${unsafeCSS(vsCodeStyle)}
+    ${unsafeCSS(resetStyle)}
+    ${unsafeCSS(codiconStyle)}
+  ` as CSSResultGroup;
 }
diff --git a/webviews/logging/components/log_view_actions.ts b/webviews/logging/components/log_view_actions.ts
index 9063bf7..b04c962 100644
--- a/webviews/logging/components/log_view_actions.ts
+++ b/webviews/logging/components/log_view_actions.ts
@@ -1,76 +1,92 @@
 // Copyright 2022 The Fuchsia Authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
-import { State } from '../src/state';
-import { LogList } from './log_list';
+import { css, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { BaseElement } from './base_element';
 
 export const DONT_WRAP_LOGS: string = 'Don\'t wrap logs';
 export const WRAP_LOGS: string = 'Wrap logs';
-export const WRAP_ACTIVE_ATTR: string = 'wrap-active';
+
+const ACTION_ICON_CLASSES: string = 'codicon icons action-icon';
 
 /**
  * Class to create action buttons that control the log list webview.
  */
-export class LogViewActions {
-  private root: HTMLDivElement;
-  private clear: HTMLDivElement;
-  private logWrap: HTMLDivElement;
+@customElement('log-view-actions')
+export class LogViewActions extends BaseElement {
+  static styles = [
+    BaseElement.styles,
+    css`
+      .action-icon {
+        background: none;
+        border: none;
+        color: var(--vscode-icon-foreground);
+        height: var(--log-action-height);
+        padding: var(--input-padding-vertical) var(--input-padding-horizontal);
+        text-align: center;
+      }
 
-  /**
-   * Container of action buttons that interact with the log viewer.
-   * @param {LogList} logList The log list webview.
-   */
-  constructor(private logList: LogList, private state: State) {
-    this.root = document.createElement('div');
-    this.root.id = 'log-view-actions';
+      .action-icon:hover {
+        background: var(--vscode-toolbar-hoverBackground);
+        border-radius: 5px;
+        cursor: pointer;
+      }
 
-    // Clear logs button
-    this.clear = document.createElement('div');
-    this.clear.id = 'clear';
-    this.clear.title = 'Clear Logs';
-    this.clear.classList.add('codicon', 'codicon-clear-all', 'icons', 'action-icon');
+      #wrap-logs {
+        color: var(--vscode-disabledForeground);
+      }
 
-    // Clear logs onclick
-    this.clear.addEventListener('click', (e) => {
-      this.logList.reset();
-    });
+      :host([wrappingLogs]) #wrap-logs {
+        color: var(--vscode-icon-foreground);
+      }
+    `
+  ];
 
-    // Wrap logs button to toggle log line text wrapping.
-    this.logWrap = document.createElement('div');
-    this.logWrap.id = 'wrap-logs';
-    this.logWrap.classList.add('codicon', 'codicon-word-wrap', 'icons', 'action-icon');
-    this.updateLogWrapBtn(this.state.shouldWrapLogs);
+  @property({ attribute: true, reflect: true, type: Boolean })
+  public wrappingLogs: boolean = false;
 
-    this.logWrap.addEventListener('click', (e) => {
-      this.state.shouldWrapLogs = !this.state.shouldWrapLogs;
-      this.updateLogWrapBtn(this.state.shouldWrapLogs);
-    });
+  public static readonly wrapLogsChangeEvent: string = 'wrap-logs-change';
+  public static readonly clearRequestedEvent: string = 'clear-requested';
 
-    // TODO(fxbug.dev/99730): Save/Load Filter Button
-    // TODO(fxbug.dev/95332): Field Visibility Dropdown
-    this.root.appendChild(this.clear);
-    this.root.appendChild(this.logWrap);
+  constructor(wrappingLogs: boolean) {
+    super();
+    this.wrappingLogs = wrappingLogs;
   }
 
-  /**
-   * Returns the core element.
-   */
-  get element(): HTMLDivElement {
-    return this.root;
+  render() {
+    const wrapLogsTitle = this.wrappingLogs ? DONT_WRAP_LOGS : WRAP_LOGS;
+
+    return html`
+      <div
+        id="clear"
+        title="Clear Logs"
+        class="${ACTION_ICON_CLASSES} codicon-clear-all"
+        @click="${this.onClearButtonClick}"
+      ></div>
+
+      <div
+        id="wrap-logs"
+        title="Wrap Logs"
+        class="${ACTION_ICON_CLASSES} codicon-word-wrap"
+        @click="${this.onWrapLogsButtonClick}"
+        title = "${wrapLogsTitle}"
+      ></div>
+        `;
   }
 
-  /**
-   * Updates the log text wrap button state (attribute, title, etc) accordingly.
-   * @param shouldWrapLogs whether or not the log wrapping is active
-   */
-  private updateLogWrapBtn(shouldWrapLogs: boolean) {
-    this.logList.logWrapping = shouldWrapLogs;
-    if (this.state.shouldWrapLogs) {
-      this.logWrap.title = DONT_WRAP_LOGS;
-      this.logWrap.setAttribute(WRAP_ACTIVE_ATTR, '');
-    } else {
-      this.logWrap.removeAttribute(WRAP_ACTIVE_ATTR);
-      this.logWrap.title = WRAP_LOGS;
-    }
+  private onClearButtonClick() {
+    this.dispatchEvent(new CustomEvent(LogViewActions.clearRequestedEvent, {
+      detail: {},
+    }));
+  }
+
+  private onWrapLogsButtonClick() {
+    this.wrappingLogs = !this.wrappingLogs;
+    this.dispatchEvent(new CustomEvent(LogViewActions.wrapLogsChangeEvent, {
+      detail: {
+        wrapLogs: this.wrappingLogs,
+      }
+    }));
   }
 }
diff --git a/webviews/logging/components/view.ts b/webviews/logging/components/view.ts
index ef00676..d1ef29a 100644
--- a/webviews/logging/components/view.ts
+++ b/webviews/logging/components/view.ts
@@ -16,25 +16,34 @@
   private logViewActions: LogViewActions;
 
   constructor(private state: State, private root: HTMLElement) {
-    this.logActionContainer = document.createElement('div');
-    this.logActionContainer.id = 'log-action-container';
-    this.logListContainer = document.createElement('div');
-    this.logListContainer.id = 'log-list-container';
+    this.logList = new LogList(state);
 
     this.logControl = new LogControl(this.state.currentFilterText);
-
     this.logControl.addEventListener(LogControl.filterChangeEvent, (e) => {
       const event = e as CustomEvent;
       const { filter, text } = event.detail;
       this.state.registerFilter(filter, text);
     });
 
-    this.logList = new LogList(state);
-    this.logViewActions = new LogViewActions(this.logList, this.state);
+    this.logViewActions = new LogViewActions(this.state.shouldWrapLogs);
+    this.logViewActions.addEventListener(LogViewActions.clearRequestedEvent, (e) => {
+      this.logList.reset();
+    });
+    this.logViewActions.addEventListener(LogViewActions.wrapLogsChangeEvent, (e) => {
+      const event = e as CustomEvent;
+      const { wrapLogs } = event.detail;
+      this.logList.logWrapping = wrapLogs;
+      this.state.shouldWrapLogs = wrapLogs;
+    });
+
+    this.logActionContainer = document.createElement('div');
+    this.logActionContainer.id = 'log-action-container';
+    this.logListContainer = document.createElement('div');
+    this.logListContainer.id = 'log-list-container';
 
     this.root.appendChild(this.logActionContainer);
     this.logActionContainer.appendChild(this.logControl);
-    this.logActionContainer.appendChild(this.logViewActions.element);
+    this.logActionContainer.appendChild(this.logViewActions);
 
     this.root.appendChild(this.logListContainer);
     this.logListContainer.appendChild(this.logList.element);
diff --git a/webviews/logging/test/log_view_actions.test.ts b/webviews/logging/test/log_view_actions.test.ts
index 722aaaa..2beeb9b 100644
--- a/webviews/logging/test/log_view_actions.test.ts
+++ b/webviews/logging/test/log_view_actions.test.ts
@@ -2,12 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import { State } from '../src/state';
-import { FakeWebviewAPi, logDataForTest } from './util';
-import chaiDom from 'chai-dom'; // not esm
 import chai from 'chai'; // not esm
-import { DONT_WRAP_LOGS, LogViewActions, WRAP_ACTIVE_ATTR, WRAP_LOGS } from '../components/log_view_actions';
-import { LogList, WRAP_LOG_TEXT_ATTR } from '../components/log_list';
+import chaiDom from 'chai-dom'; // not esm
+import { DONT_WRAP_LOGS, LogViewActions, WRAP_LOGS } from '../components/log_view_actions';
 
 before(function () {
   chai.should();
@@ -15,74 +12,91 @@
 });
 
 describe('LogViewActions', () => {
-  let root: HTMLElement;
-  let vscode: FakeWebviewAPi;
-  let state: State;
-  let logList: LogList;
+  let logViewActions: LogViewActions;
 
   beforeEach(() => {
-    root = document.createElement('div');
-    document.body.appendChild(root);
-    vscode = new FakeWebviewAPi();
-    state = new State(vscode);
-    logList = new LogList(state);
+    logViewActions = document.createElement('log-view-actions') as LogViewActions;
+    document.body.appendChild(logViewActions);
   });
 
   afterEach(() => {
-    root.remove();
+    logViewActions.remove();
   });
 
   describe('#constructor', () => {
-    it('crates log view actions buttons', () => {
-      const logViewActions = new LogViewActions(logList, state);
-      const el = logViewActions.element;
-      el.children.length.should.equal(2);
-      el.children[0].id.should.equal('clear');
+    it('crates log view actions buttons', async () => {
+      await logViewActions.updateComplete;
+      logViewActions.shadowRoot!.children.length.should.equal(2);
+      logViewActions.shadowRoot!.children[0].id.should.equal('clear');
+      logViewActions.shadowRoot!.children[1].id.should.equal('wrap-logs');
     });
 
-    it('takes state into account when creating the log wrap btn', () => {
-      state.shouldWrapLogs = false;
-      const logViewActions = new LogViewActions(logList, state);
-      const wrapLogsButton = logViewActions.element.querySelector('#wrap-logs') as HTMLDivElement;
-      wrapLogsButton.hasAttribute(WRAP_ACTIVE_ATTR).should.be.false;
+    it('takes state into account when creating the log wrap btn', async () => {
+      let logViewActions = new LogViewActions(false);
+      document.body.appendChild(logViewActions);
+      await logViewActions.updateComplete;
+      let wrapLogsButton = logViewActions.shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
+      logViewActions.wrappingLogs.should.be.false;
       wrapLogsButton.title.should.equal(WRAP_LOGS);
-      logList.element.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.false;
+      logViewActions.remove();
+
+      logViewActions = new LogViewActions(true);
+      document.body.appendChild(logViewActions);
+      await logViewActions.updateComplete;
+      wrapLogsButton = logViewActions.shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
+      logViewActions.wrappingLogs.should.be.true;
+      wrapLogsButton.title.should.equal(DONT_WRAP_LOGS);
     });
   });
 
   describe('on clear button press', () => {
-    it('reset the log list', () => {
-      const logViewActions = new LogViewActions(logList, state);
-      const el = logViewActions.element;
-      const clearButton = el.querySelector('#clear') as HTMLDivElement;
-      logList.addLog(logDataForTest('core/foo'));
-      const logsList = logList.element;
-      logsList.children.length.should.equal(2);
-      clearButton.dispatchEvent(new MouseEvent('click'));
-      logsList.children.length.should.equal(1);
+    it('emits clear requested event', async () => {
+      await logViewActions.updateComplete;
+      const clearButton = logViewActions.shadowRoot!.querySelector('#clear') as HTMLDivElement;
+      const promise = new Promise<{}>((resolve) => {
+        logViewActions.addEventListener(LogViewActions.clearRequestedEvent, (e) => {
+          resolve({});
+        });
+      });
+      clearButton.click();
+      await promise;
     });
   });
 
   describe('on wrap logs button press', () => {
-    it('sets a wrap-log-text attribute in the log list', () => {
-      const logViewActions = new LogViewActions(logList, state);
-      const el = logViewActions.element;
-      const wrapLogsButton = el.querySelector('#wrap-logs') as HTMLDivElement;
-      wrapLogsButton.hasAttribute(WRAP_ACTIVE_ATTR).should.be.true;
-      logList.element.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.true;
+    it('emits log wrapping event and updates itself', async () => {
+      await logViewActions.updateComplete;
+      const wrapLogsButton = logViewActions
+        .shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
+      let promise = new Promise<{ wrapLogs: boolean }>((resolve) => {
+        logViewActions.addEventListener(LogViewActions.wrapLogsChangeEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+      wrapLogsButton.click();
+
+      let payload = await promise;
+      payload.wrapLogs.should.be.true;
+      await logViewActions.updateComplete;
+      logViewActions.wrappingLogs.should.be.true;
       wrapLogsButton.title.should.equal(DONT_WRAP_LOGS);
-      state.shouldWrapLogs.should.be.true;
 
-      wrapLogsButton.dispatchEvent(new MouseEvent('click'));
-      wrapLogsButton.hasAttribute(WRAP_ACTIVE_ATTR).should.be.false;
-      logList.element.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.false;
+      // Clicking again should toggle the state.
+
+      promise = new Promise<{ wrapLogs: boolean }>((resolve) => {
+        logViewActions.addEventListener(LogViewActions.wrapLogsChangeEvent, (e) => {
+          const event = e as CustomEvent;
+          resolve(event.detail);
+        });
+      });
+      wrapLogsButton.click();
+
+      payload = await promise;
+      payload.wrapLogs.should.be.false;
+      await logViewActions.updateComplete;
+      logViewActions.wrappingLogs.should.be.false;
       wrapLogsButton.title.should.equal(WRAP_LOGS);
-      state.shouldWrapLogs.should.be.false;
-
-      wrapLogsButton.dispatchEvent(new MouseEvent('click'));
-      wrapLogsButton.hasAttribute(WRAP_ACTIVE_ATTR).should.be.true;
-      logList.element.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.true;
-      state.shouldWrapLogs.should.be.true;
     });
   });
 });
diff --git a/webviews/logging/test/view.test.ts b/webviews/logging/test/view.test.ts
index 884875b..37b5b9d 100644
--- a/webviews/logging/test/view.test.ts
+++ b/webviews/logging/test/view.test.ts
@@ -3,13 +3,15 @@
 // found in the LICENSE file.
 
 import { CURRENT_VERSION, State } from '../src/state';
-import { FakeWebviewAPi } from './util';
+import { FakeWebviewAPi, logDataForTest } from './util';
 import { LoggingView } from '../components/view';
 import * as constant from '../src/constants';
 import chai from 'chai';
 import chaiDom from 'chai-dom'; // not esm
 import { LogControl } from '../components/log_control';
 import { Filter, FilterExpression } from '../src/filter';
+import { LogViewActions } from '../components/log_view_actions';
+import { WRAP_LOG_TEXT_ATTR } from '../components/log_list';
 
 before(function () {
   chai.should();
@@ -46,7 +48,7 @@
       const container = root.children[0];
       container.children.length.should.equal(2);
       container.children[0].tagName.toLowerCase().should.equal('log-control');
-      container.children[1].id.should.equal('log-view-actions');
+      container.children[1].tagName.toLowerCase().should.equal('log-view-actions');
     });
 
     it('initializes log control with the current state filters', async () => {
@@ -56,13 +58,34 @@
         fields: constant.LOGS_HEADERS,
         wrappingLogs: true
       });
-      // @ts-ignore
       new LoggingView(new State(vscode), root);
       let logControl = root.getElementsByTagName('log-control')[0] as LogControl;
       await logControl.updateComplete;
       logControl.value.should.equal(filterText);
     });
 
+    it('initializes log actions view with the current state wrapping logs', async () => {
+      vscode.setState({
+        version: CURRENT_VERSION,
+        filter: filterText,
+        fields: constant.LOGS_HEADERS,
+        wrappingLogs: true
+      });
+      new LoggingView(new State(vscode), root);
+      let logViewActions = root.getElementsByTagName('log-view-actions')[0] as LogViewActions;
+      await logViewActions.updateComplete;
+      logViewActions.wrappingLogs.should.be.true;
+    });
+
+    it('crates log list container with log list', () => {
+      new LoggingView(state, root);
+      const container = root.children[1];
+      container.children.length.should.equal(1);
+      container.children[0].id.should.equal('log-list');
+    });
+  });
+
+  describe('log control integration', () => {
     it('updates the state on log control filter changes', async () => {
       new LoggingView(state, root);
       let logControl = root.getElementsByTagName('log-control')[0] as LogControl;
@@ -84,12 +107,46 @@
       ]));
       state.currentFilterText.should.deep.equal(filterText);
     });
+  });
 
-    it('crates log list container with log list', () => {
-      new LoggingView(state, root);
-      const container = root.children[1];
-      container.children.length.should.equal(1);
-      container.children[0].id.should.equal('log-list');
+  describe('log actions view integration', () => {
+    it('clears the log list when the clear button is clicked', async () => {
+      const view = new LoggingView(state, root);
+      const logList = root.querySelector('#log-list') as HTMLElement;
+      view.addLog(logDataForTest('core/foo'));
+      logList.children.length.should.equal(2);
+
+      let logViewActions = root.getElementsByTagName('log-view-actions')[0] as LogViewActions;
+      await logViewActions.updateComplete;
+      let clearButton = logViewActions.shadowRoot!.querySelector('#clear') as HTMLDivElement;
+      clearButton.click();
+      logList.children.length.should.equal(1);
+    });
+
+    it('updates the state and log list when the wrap logs button is clicked', async () => {
+      const view = new LoggingView(state, root);
+      const logList = root.querySelector('#log-list') as HTMLElement;
+      view.addLog(logDataForTest('core/foo'));
+      logList.children.length.should.equal(2);
+
+      let logViewActions = root.getElementsByTagName('log-view-actions')[0] as LogViewActions;
+      await logViewActions.updateComplete;
+      let wrapLogsButton = logViewActions.shadowRoot!.querySelector('#wrap-logs') as HTMLDivElement;
+
+      // Clicking the button for the first should update the other elements to true.
+      // Since we started on true (default state value).
+      wrapLogsButton.click();
+      await logViewActions.updateComplete;
+
+      state.shouldWrapLogs.should.be.false;
+      logList.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.false;
+
+      // Clicking the button again should update the other elements to false.
+      wrapLogsButton.click();
+      await logViewActions.updateComplete;
+
+      state.shouldWrapLogs.should.be.true;
+      logList.hasAttribute(WRAP_LOG_TEXT_ATTR).should.be.true;
     });
   });
 });