Add status icon to the `Owned Files` tab header

When files owner opens a change screen status icon immediately indicates
the following status of owned files:
* they need a review with orange clock icon and the following tooltip
  when hovered

    Missing approval for X file[s].

* they are already approved (also by someone else) with green checked
  icon and the following tooltip when hovered

    X file[s] already approved.

New functionality was covered in the unit tests.

Bug: Issue 383596444
Change-Id: I1cf4fccf692d966e3658b42aa153a465242a7b09
diff --git a/owners/web/gr-owned-files.ts b/owners/web/gr-owned-files.ts
index 4d27e03..13398d0 100644
--- a/owners/web/gr-owned-files.ts
+++ b/owners/web/gr-owned-files.ts
@@ -42,13 +42,29 @@
   truncatePath,
 } from './utils';
 
+const STATUS_CODE = {
+  MISSING: 'missing',
+  APPROVED: 'approved',
+};
+
+const STATUS_ICON = {
+  [STATUS_CODE.MISSING]: 'schedule',
+  [STATUS_CODE.APPROVED]: 'check',
+};
+
+export interface OwnedFilesInfo {
+  ownedFiles: string[];
+  numberOfPending: number;
+  numberOfApproved: number;
+}
+
 const common = OwnersMixin(LitElement);
 
 class OwnedFilesCommon extends common {
   @property({type: Object})
   revision?: RevisionInfo;
 
-  protected ownedFiles?: string[];
+  protected ownedFilesInfo?: OwnedFilesInfo;
 
   protected override willUpdate(changedProperties: PropertyValues): void {
     super.willUpdate(changedProperties);
@@ -58,7 +74,7 @@
       this.change,
       this.revision,
       this.user,
-      this.ownedFiles
+      this.ownedFilesInfo?.ownedFiles
     );
   }
 
@@ -67,13 +83,18 @@
   }
 
   private computeOwnedFiles() {
-    this.ownedFiles = ownedFiles(this.user?.account, this.filesOwners);
+    this.ownedFilesInfo = ownedFiles(this.user?.account, this.filesOwners);
   }
 }
 
 export const OWNED_FILES_TAB_HEADER = 'owned-files-tab-header';
 @customElement(OWNED_FILES_TAB_HEADER)
 export class OwnedFilesTabHeader extends OwnedFilesCommon {
+  @property({type: String, reflect: true, attribute: 'files-status'})
+  filesStatus?: string;
+
+  private ownedFiles: number | undefined;
+
   static override get styles() {
     return [
       ...OwnedFilesCommon.commonStyles(),
@@ -81,10 +102,34 @@
         [hidden] {
           display: none;
         }
+        gr-icon {
+          padding: var(--spacing-xs) 0px;
+          margin-left: 3px;
+        }
+        :host([files-status='approved']) gr-icon.status {
+          color: var(--positive-green-text-color);
+        }
+        :host([files-status='missing']) gr-icon.status {
+          color: #ffa62f;
+        }
       `,
     ];
   }
 
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    super.willUpdate(changedProperties);
+
+    if (this.hidden || this.ownedFilesInfo === undefined) {
+      this.filesStatus = undefined;
+      this.ownedFiles = undefined;
+    } else {
+      [this.filesStatus, this.ownedFiles] =
+        this.ownedFilesInfo.numberOfApproved > 0
+          ? [STATUS_CODE.APPROVED, this.ownedFilesInfo.numberOfApproved]
+          : [STATUS_CODE.MISSING, this.ownedFilesInfo.numberOfPending];
+    }
+  }
+
   override render() {
     // even if `nothing` is returned Gerrit still shows the pointer and allows
     // clicking at it, redirecting to the empty tab when done; traverse through
@@ -105,7 +150,55 @@
     if (tabParent && tabParent.getAttribute('disabled')) {
       tabParent.removeAttribute('disabled');
     }
-    return html`<div>Owned Files</div>`;
+
+    if (
+      this.ownedFilesInfo === undefined ||
+      this.filesStatus === undefined ||
+      this.ownedFiles === undefined
+    ) {
+      return html`<div>Owned Files</div>`;
+    }
+
+    const icon = STATUS_ICON[this.filesStatus];
+    const [info, summary] = this.buildInfoAndSummary(
+      this.filesStatus,
+      this.ownedFilesInfo.numberOfApproved,
+      this.ownedFilesInfo.numberOfPending
+    );
+    return html` <div>
+      Owned Files
+      <gr-tooltip-content
+        title=${info}
+        aria-label=${summary}
+        aria-description=${info}
+        has-tooltip
+      >
+        <gr-icon
+          id="owned-files-status"
+          class="status"
+          icon=${icon}
+          aria-hidden="true"
+        ></gr-icon>
+      </gr-tooltip-content>
+    </div>`;
+  }
+
+  private buildInfoAndSummary(
+    filesStatus: string,
+    filesApproved: number,
+    filesPending: number
+  ): [string, string] {
+    const count = filesApproved + filesPending;
+    const plural = count === 1 ? '' : 's';
+    const info = `${
+      STATUS_CODE.APPROVED === filesStatus
+        ? `${count} file${plural} already approved.`
+        : `Missing approval for ${count} file${plural}.`
+    }`;
+    const summary = `${
+      STATUS_CODE.APPROVED === filesStatus ? 'Approved' : 'Missing'
+    }`;
+    return [info, summary];
   }
 }
 
@@ -278,7 +371,7 @@
         id="ownedFilesList"
         .change=${this.change}
         .revision=${this.revision}
-        .ownedFiles=${this.ownedFiles}
+        .ownedFiles=${this.ownedFilesInfo?.ownedFiles}
       >
       </gr-owned-files-list>
     `;
@@ -319,7 +412,7 @@
   groupPrefix: string,
   files: OwnedFiles,
   emailWithoutDomain?: string
-) {
+): string[] {
   const ownedFiles = [];
   for (const file of Object.keys(files ?? [])) {
     if (
@@ -345,7 +438,7 @@
 export function ownedFiles(
   owner?: AccountInfo,
   filesOwners?: FilesOwners
-): string[] | undefined {
+): OwnedFilesInfo | undefined {
   if (
     !owner ||
     !filesOwners ||
@@ -356,20 +449,23 @@
 
   const groupPrefix = 'group/';
   const emailWithoutDomain = toEmailWithoutDomain(owner.email);
-  return [
-    ...collectOwnedFiles(
-      owner,
-      groupPrefix,
-      filesOwners.files,
-      emailWithoutDomain
-    ),
-    ...collectOwnedFiles(
-      owner,
-      groupPrefix,
-      filesOwners.files_approved,
-      emailWithoutDomain
-    ),
-  ];
+  const pendingFiles = collectOwnedFiles(
+    owner,
+    groupPrefix,
+    filesOwners.files,
+    emailWithoutDomain
+  );
+  const approvedFiles = collectOwnedFiles(
+    owner,
+    groupPrefix,
+    filesOwners.files_approved,
+    emailWithoutDomain
+  );
+  return {
+    ownedFiles: [...pendingFiles, ...approvedFiles],
+    numberOfPending: pendingFiles.length,
+    numberOfApproved: approvedFiles.length,
+  } as OwnedFilesInfo;
 }
 
 export function computeDiffUrl(
diff --git a/owners/web/gr-owned-files_test.ts b/owners/web/gr-owned-files_test.ts
index af9f814..d7bef24 100644
--- a/owners/web/gr-owned-files_test.ts
+++ b/owners/web/gr-owned-files_test.ts
@@ -26,7 +26,7 @@
   EDIT,
   SubmitRequirementResultInfo,
 } from '@gerritcodereview/typescript-api/rest-api';
-import {ownedFiles, shouldHide} from './gr-owned-files';
+import {ownedFiles, OwnedFilesInfo, shouldHide} from './gr-owned-files';
 import {FilesOwners, GroupOwner, Owner} from './owners-service';
 import {deepEqual} from './utils';
 import {User, UserRole} from './owners-model';
@@ -50,6 +50,11 @@
       files,
       files_approved,
     } as unknown as FilesOwners;
+    const emptyOwnerFilesInfo = {
+      ownedFiles: [],
+      numberOfApproved: 0,
+      numberOfPending: 0,
+    } as unknown as OwnedFilesInfo;
 
     test('ownedFiles - should be `undefined` when owner is `undefined`', () => {
       const undefinedOwner = undefined;
@@ -63,15 +68,19 @@
 
     test('ownedFiles - should return empty owned file when no files are owned by user', () => {
       const user = account(2);
-      assert.equal(deepEqual(ownedFiles(user, filesOwners), []), true);
+      assert.equal(
+        deepEqual(ownedFiles(user, filesOwners), emptyOwnerFilesInfo),
+        true
+      );
     });
 
     test('ownedFiles - should return owned files', () => {
       assert.equal(
-        deepEqual(ownedFiles(owner, filesOwners), [
-          ownedFile,
-          ownedApprovedFile,
-        ]),
+        deepEqual(ownedFiles(owner, filesOwners), {
+          ownedFiles: [ownedFile, ownedApprovedFile],
+          numberOfApproved: 1,
+          numberOfPending: 1,
+        }),
         true
       );
     });
@@ -84,7 +93,11 @@
         },
       } as unknown as FilesOwners;
       assert.equal(
-        deepEqual(ownedFiles(owner, filesOwners), [ownedFile]),
+        deepEqual(ownedFiles(owner, filesOwners), {
+          ownedFiles: [ownedFile],
+          numberOfApproved: 0,
+          numberOfPending: 1,
+        }),
         true
       );
     });
@@ -97,14 +110,21 @@
         },
       } as unknown as FilesOwners;
       assert.equal(
-        deepEqual(ownedFiles(owner, filesOwners), [ownedFile]),
+        deepEqual(ownedFiles(owner, filesOwners), {
+          ownedFiles: [ownedFile],
+          numberOfApproved: 1,
+          numberOfPending: 0,
+        }),
         true
       );
     });
 
     test('ownedFiles - should NOT match file owner over email without domain or full name when account id is different', () => {
       const notFileOwner = {...owner, _account_id: 2 as unknown as AccountId};
-      assert.equal(deepEqual(ownedFiles(notFileOwner, filesOwners), []), true);
+      assert.equal(
+        deepEqual(ownedFiles(notFileOwner, filesOwners), emptyOwnerFilesInfo),
+        true
+      );
     });
   });