Display file owners when a owner's status icon is hovered over

When one hoovers over the file owner's status icon the panel is shown
and it contains file owners (up to 5 per file).

TODOS:
* add information if any of them already voted on the change
* add copy button

Bug: Issue 373151160
Change-Id: I8a36e413e35776d70bee5858ea7fd70730e2e4a6
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
index ca761fd..b989eba 100644
--- a/owners/web/gr-owners.ts
+++ b/owners/web/gr-owners.ts
@@ -16,14 +16,16 @@
  */
 
 import {Subscription} from 'rxjs';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues, CSSResult} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {
+  AccountInfo,
   ChangeInfo,
   ChangeStatus,
 } from '@gerritcodereview/typescript-api/rest-api';
 import {
   FilesOwners,
+  isOwner,
   OWNERS_SUBMIT_REQUIREMENT,
   OwnersService,
 } from './owners-service';
@@ -45,18 +47,13 @@
   [STATUS_CODE.MISSING]: 'schedule',
 };
 
-const STATUS_SUMMARY = {
-  [STATUS_CODE.MISSING]: 'Missing',
-};
-
-const STATUS_TOOLTIP = {
-  [STATUS_CODE.MISSING]: 'Missing owner approval',
-};
-
 const FILE_STATUS = {
   [FileStatus.NEEDS_APPROVAL]: STATUS_CODE.MISSING,
 };
 
+const DISPLAY_OWNERS_FOR_FILE_LIMIT = 5;
+const LIMITED_FILES_OWNERS_TOOLTIP = `File owners limited to first ${DISPLAY_OWNERS_FOR_FILE_LIMIT} accounts.`;
+
 // Lit mixin definition as described in https://lit.dev/docs/composition/mixins/
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type Constructor<T> = new (...args: any[]) => T;
@@ -180,6 +177,7 @@
 export class FileOwnersColumnHeader extends common {
   static override get styles() {
     return [
+      window?.Gerrit?.styles.font as CSSResult,
       css`
         :host() {
           display: block;
@@ -211,8 +209,14 @@
   @property({type: String, reflect: true, attribute: 'file-status'})
   fileStatus?: string;
 
+  private owners?: AccountInfo[];
+
+  // taken from Gerrit's common-util.ts
+  private uniqueId = Math.random().toString(36).substring(2);
+
   static override get styles() {
     return [
+      window?.Gerrit?.styles.font as CSSResult,
       css`
         :host {
           display: flex;
@@ -238,21 +242,110 @@
       return nothing;
     }
 
-    const info = STATUS_SUMMARY[this.fileStatus];
-    const tooltip = STATUS_TOOLTIP[this.fileStatus];
     const icon = STATUS_ICON[this.fileStatus];
     return html`
-    <gr-tooltip-content
-        title=${tooltip}
-        aria-label=${info}
-        aria-description=${info}
-        has-tooltip
-      >
-        <gr-icon class="status" icon=${icon} aria-hidden="true"></gr-icon>
-      </gt-tooltip-content>
+      <gr-icon
+        id="${this.pathId()}"
+        class="status"
+        icon=${icon}
+        aria-hidden="true"
+      ></gr-icon>
+      ${this.renderFileOwners()}
     `;
   }
 
+  private pathId(): string {
+    return `path-${this.uniqueId}`;
+  }
+
+  private renderFileOwners() {
+    const owners = this.owners ?? [];
+    const splicedOwners = owners.splice(0, DISPLAY_OWNERS_FOR_FILE_LIMIT);
+    const showEllipsis = owners.length > DISPLAY_OWNERS_FOR_FILE_LIMIT;
+    // inlining <style> here is ugly but an alternative would be to copy the `HovercardMixin` from Gerrit and implement hoover from scratch
+    return html`<gr-hovercard for="${this.pathId()}">
+      <style>
+        #file-owners-hoovercard {
+          min-width: 256px;
+          max-width: 256px;
+          margin: -10px;
+          padding: var(--spacing-xl) 0 var(--spacing-m) 0;
+        }
+        h3 {
+          font: inherit;
+        }
+        .heading-3 {
+          margin-left: -2px;
+          font-family: var(--header-font-family);
+          font-size: var(--font-size-h3);
+          font-weight: var(--font-weight-h3);
+          line-height: var(--line-height-h3);
+        }
+        .row {
+          display: flex;
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        div.sectionIcon gr-icon {
+          position: relative;
+        }
+        div.sectionContent .row {
+          margin-left: 2px;
+        }
+        div.sectionContent .notLast {
+          margin-bottom: 2px;
+        }
+        div.sectionContent .ellipsis {
+          margin-left: 5px;
+        }
+      </style>
+      <div id="file-owners-hoovercard">
+        <div class="section">
+          <div class="sectionIcon">
+            <gr-icon class="status" icon="info" aria-hidden="true"></gr-icon>
+          </div>
+          <div class="sectionContent">
+            <h3 class="name heading-3">
+              <span>Needs Owners' Approval</span>
+            </h3>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionContent">
+            ${splicedOwners.map(
+              (owner, idx) => html`
+                <div
+                  class="row ${showEllipsis || idx + 1 < splicedOwners.length
+                    ? 'notLast'
+                    : ''}"
+                >
+                  <gr-account-label .account=${owner}></gr-account-label>
+                </div>
+              `
+            )}
+            ${showEllipsis
+              ? html`
+                <gr-tooltip-content
+                  title=${LIMITED_FILES_OWNERS_TOOLTIP}
+                  aria-label="limited-file-onwers"
+                  aria-description=${LIMITED_FILES_OWNERS_TOOLTIP}
+                  has-tooltip
+                >
+                  <div class="row ellipsis">...</div>
+                </gt-tooltip-content>`
+              : nothing}
+          </div>
+        </div>
+      </div>
+    </gr-hovercard>`;
+  }
+
   protected override willUpdate(changedProperties: PropertyValues): void {
     super.willUpdate(changedProperties);
     this.computeFileState();
@@ -269,10 +362,15 @@
       fileOwnership.fileStatus === FileStatus.NOT_OWNED_OR_APPROVED
     ) {
       this.fileStatus = undefined;
+      this.owners = undefined;
       return;
     }
 
     this.fileStatus = FILE_STATUS[fileOwnership.fileStatus];
+    // TODO for the time being filter out or group owners - to be decided what/how to display them
+    this.owners = (fileOwnership.owners ?? [])
+      .filter(isOwner)
+      .map(o => ({_account_id: o.id} as unknown as AccountInfo));
   }
 }
 
@@ -324,9 +422,11 @@
     return undefined;
   }
 
-  const status =
-    allFilesApproved || !(filesOwners.files ?? {})[path]
-      ? FileStatus.NOT_OWNED_OR_APPROVED
-      : FileStatus.NEEDS_APPROVAL;
-  return {fileStatus: status} as unknown as FileOwnership;
+  const fileOwners = (filesOwners.files ?? {})[path];
+  return (allFilesApproved || !fileOwners
+    ? {fileStatus: FileStatus.NOT_OWNED_OR_APPROVED}
+    : {
+        fileStatus: FileStatus.NEEDS_APPROVAL,
+        owners: fileOwners,
+      }) as unknown as FileOwnership;
 }
diff --git a/owners/web/gr-owners_test.ts b/owners/web/gr-owners_test.ts
index ff76c9c..0b4a5ec 100644
--- a/owners/web/gr-owners_test.ts
+++ b/owners/web/gr-owners_test.ts
@@ -241,7 +241,10 @@
       assert.equal(
         deepEqual(
           getFileOwnership(path, !allFilesApproved, fileOwnersWithPath),
-          {fileStatus: FileStatus.NEEDS_APPROVAL} as FileOwnership
+          {
+            fileStatus: FileStatus.NEEDS_APPROVAL,
+            owners: [{name: 'John', id: 1}],
+          } as FileOwnership
         ),
         true
       );
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
index d1ecaab..5ed7df3 100644
--- a/owners/web/owners-model.ts
+++ b/owners/web/owners-model.ts
@@ -21,7 +21,7 @@
   ChangeInfo,
   RevisionPatchSetNum,
 } from '@gerritcodereview/typescript-api/rest-api';
-import {FilesOwners, OwnersService} from './owners-service';
+import {FileOwner, FilesOwners, OwnersService} from './owners-service';
 import {deepEqual} from './utils';
 
 export interface PatchRange {
@@ -56,6 +56,7 @@
  */
 export interface FileOwnership {
   fileStatus: FileStatus;
+  owners?: FileOwner[];
 }
 
 let ownersModel: OwnersModel | undefined;
diff --git a/owners/web/owners-service.ts b/owners/web/owners-service.ts
index 91107bf..479a739 100644
--- a/owners/web/owners-service.ts
+++ b/owners/web/owners-service.ts
@@ -34,6 +34,11 @@
   id: number;
 }
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isOwner(o: any): o is Owner {
+  return o && typeof o.id === 'number' && typeof o.name === 'string';
+}
+
 export type FileOwner = Owner | GroupOwner;
 
 export interface OwnedFiles {