Indicate file that needs file owner's review

When logged in user opens details screen then:
* if any file has missing file owner's approval then show and populate
  the `Status` column (prior the `File Status` column) with an icon for
  each file that needs approval from owner
* if all files are approved or repository has no owners etc.. then no
  statuses are shown or populated

TODO: ATM `Missing owner approval` tooltip is displayed when one hovers
over the file that has addtional icon. In the follow up change(s) it
will be extended to display the file owners.

Bug: Issue 373151160
Change-Id: Ice722ec49710377991018b151a1521217c5ed0c1
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
index 85e92dc..ca761fd 100644
--- a/owners/web/gr-owners.ts
+++ b/owners/web/gr-owners.ts
@@ -16,7 +16,7 @@
  */
 
 import {Subscription} from 'rxjs';
-import {LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {customElement, property, state} from 'lit/decorators';
 import {
   ChangeInfo,
@@ -28,13 +28,40 @@
   OwnersService,
 } from './owners-service';
 import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
-import {ModelLoader, OwnersModel, PatchRange, UserRole} from './owners-model';
+import {
+  FileOwnership,
+  FileStatus,
+  ModelLoader,
+  OwnersModel,
+  PatchRange,
+  UserRole,
+} from './owners-model';
+
+const STATUS_CODE = {
+  MISSING: 'missing',
+};
+
+const STATUS_ICON = {
+  [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,
+};
 
 // 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;
 
-interface CommonInterface {
+interface CommonInterface extends LitElement {
   change?: ChangeInfo;
   patchRange?: PatchRange;
   restApi?: RestPluginApi;
@@ -123,7 +150,12 @@
         this.model = model;
       }
 
-      this.hidden = shouldHide(this.change, this.patchRange, this.userRole);
+      this.hidden = shouldHide(
+        this.change,
+        this.patchRange,
+        this.allFilesApproved,
+        this.userRole
+      );
     }
 
     protected onModelUpdate() {
@@ -147,14 +179,23 @@
 @customElement(FILE_OWNERS_COLUMN_HEADER)
 export class FileOwnersColumnHeader extends common {
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host() {
+          display: block;
+          padding-right: var(--spacing-m);
+          width: 4em;
+        }
+        :host[hidden] {
+          display: none;
+        }
+      `,
+    ];
   }
 
   override render() {
-    console.log(
-      `hidden: ${this.hidden}, userRole: ${this.userRole}, allFilesApproved: ${this.allFilesApproved}`
-    );
-    return nothing;
+    if (this.hidden) return nothing;
+    return html`<div>Status</div>`;
   }
 }
 
@@ -167,25 +208,78 @@
   @property({type: String})
   oldPath?: string;
 
+  @property({type: String, reflect: true, attribute: 'file-status'})
+  fileStatus?: string;
+
   static override get styles() {
-    return [];
+    return [
+      css`
+        :host {
+          display: flex;
+          padding-right: var(--spacing-m);
+          width: 4em;
+          text-align: center;
+        }
+        :host[hidden] {
+          display: none;
+        }
+        gr-icon {
+          padding: var(--spacing-xs) 0px;
+        }
+        :host([file-status='missing']) gr-icon.status {
+          color: #ffa62f;
+        }
+      `,
+    ];
   }
 
   override render() {
-    console.log(
-      `hidden: ${this.hidden}, userRole: ${this.userRole}, path: ${
-        this.path
-      }, oldPath: ${this.oldPath}, filesOwners: ${JSON.stringify(
-        this.filesOwners
-      )}`
+    if (this.hidden || !this.fileStatus) {
+      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>
+    `;
+  }
+
+  protected override willUpdate(changedProperties: PropertyValues): void {
+    super.willUpdate(changedProperties);
+    this.computeFileState();
+  }
+
+  private computeFileState(): void {
+    const fileOwnership = getFileOwnership(
+      this.path,
+      this.allFilesApproved,
+      this.filesOwners
     );
-    return nothing;
+    if (
+      !fileOwnership ||
+      fileOwnership.fileStatus === FileStatus.NOT_OWNED_OR_APPROVED
+    ) {
+      this.fileStatus = undefined;
+      return;
+    }
+
+    this.fileStatus = FILE_STATUS[fileOwnership.fileStatus];
   }
 }
 
 export function shouldHide(
   change?: ChangeInfo,
   patchRange?: PatchRange,
+  allFilesApproved?: boolean,
   userRole?: UserRole
 ): boolean {
   // don't show owners when no change or change is merged
@@ -212,6 +306,7 @@
 
   // show owners when they apply to the change and for logged in user
   if (
+    !allFilesApproved &&
     change.submit_requirements &&
     change.submit_requirements.find(r => r.name === OWNERS_SUBMIT_REQUIREMENT)
   ) {
@@ -219,3 +314,19 @@
   }
   return true;
 }
+
+export function getFileOwnership(
+  path?: string,
+  allFilesApproved?: boolean,
+  filesOwners?: FilesOwners
+): FileOwnership | undefined {
+  if (path === undefined || filesOwners === undefined) {
+    return undefined;
+  }
+
+  const status =
+    allFilesApproved || !(filesOwners.files ?? {})[path]
+      ? FileStatus.NOT_OWNED_OR_APPROVED
+      : FileStatus.NEEDS_APPROVAL;
+  return {fileStatus: status} as unknown as FileOwnership;
+}
diff --git a/owners/web/gr-owners_test.ts b/owners/web/gr-owners_test.ts
index bf29042..ff76c9c 100644
--- a/owners/web/gr-owners_test.ts
+++ b/owners/web/gr-owners_test.ts
@@ -17,15 +17,19 @@
 
 import {assert} from '@open-wc/testing';
 
-import {shouldHide} from './gr-owners';
-import {PatchRange, UserRole} from './owners-model';
+import {getFileOwnership, shouldHide} from './gr-owners';
+import {FileOwnership, FileStatus, PatchRange, UserRole} from './owners-model';
 import {
   ChangeInfo,
   ChangeStatus,
   SubmitRequirementResultInfo,
 } from '@gerritcodereview/typescript-api/rest-api';
+import {FilesOwners} from './owners-service';
+import {deepEqual} from './utils';
 
 suite('owners status tests', () => {
+  const allFilesApproved = true;
+
   suite('shouldHide tests', () => {
     const loggedIn = getRandom(UserRole.CHANGE_OWNER, UserRole.OTHER);
 
@@ -33,7 +37,12 @@
       const undefinedChange = undefined;
       const definedPatchRange = {} as unknown as PatchRange;
       assert.equal(
-        shouldHide(undefinedChange, definedPatchRange, loggedIn),
+        shouldHide(
+          undefinedChange,
+          definedPatchRange,
+          allFilesApproved,
+          loggedIn
+        ),
         true
       );
     });
@@ -42,7 +51,12 @@
       const definedChange = {} as unknown as ChangeInfo;
       const undefinedPatchRange = undefined;
       assert.equal(
-        shouldHide(definedChange, undefinedPatchRange, loggedIn),
+        shouldHide(
+          definedChange,
+          undefinedPatchRange,
+          allFilesApproved,
+          loggedIn
+        ),
         true
       );
     });
@@ -53,7 +67,12 @@
       } as unknown as ChangeInfo;
       const definedPatchRange = {} as unknown as PatchRange;
       assert.equal(
-        shouldHide(abandonedChange, definedPatchRange, loggedIn),
+        shouldHide(
+          abandonedChange,
+          definedPatchRange,
+          allFilesApproved,
+          loggedIn
+        ),
         true
       );
     });
@@ -63,7 +82,10 @@
         status: ChangeStatus.MERGED,
       } as unknown as ChangeInfo;
       const definedPatchRange = {} as unknown as PatchRange;
-      assert.equal(shouldHide(mergedChange, definedPatchRange, loggedIn), true);
+      assert.equal(
+        shouldHide(mergedChange, definedPatchRange, allFilesApproved, loggedIn),
+        true
+      );
     });
 
     test('shouldHide - should be `true` if not on the latest PS', () => {
@@ -75,7 +97,10 @@
         current_revision: 'current_rev',
       } as unknown as ChangeInfo;
       const patchRangeOnPs1 = {patchNum: 1} as unknown as PatchRange;
-      assert.equal(shouldHide(changeWithPs2, patchRangeOnPs1, loggedIn), true);
+      assert.equal(
+        shouldHide(changeWithPs2, patchRangeOnPs1, allFilesApproved, loggedIn),
+        true
+      );
     });
 
     const change = {
@@ -88,7 +113,10 @@
     const patchRange = {patchNum: 1} as unknown as PatchRange;
 
     test('shouldHide - should be `true` when change has no submit requirements', () => {
-      assert.equal(shouldHide(change, patchRange, loggedIn), true);
+      assert.equal(
+        shouldHide(change, patchRange, !allFilesApproved, loggedIn),
+        true
+      );
     });
 
     test('shouldHide - should be `true` when change has no `Owner-Approval` submit requirements', () => {
@@ -99,34 +127,56 @@
         ] as unknown as SubmitRequirementResultInfo[],
       };
       assert.equal(
-        shouldHide(changeWithDifferentSubmitReqs, patchRange, loggedIn),
+        shouldHide(
+          changeWithDifferentSubmitReqs,
+          patchRange,
+          !allFilesApproved,
+          loggedIn
+        ),
         true
       );
     });
 
-    test('shouldHide - should be `true` when user is not logged in', () => {
-      const changeWithSubmitRequirements = {
-        ...change,
-        submit_requirements: [
-          {name: 'Owner-Approval'},
-        ] as unknown as SubmitRequirementResultInfo[],
-      };
+    const changeWithSubmitRequirements = {
+      ...change,
+      submit_requirements: [
+        {name: 'Owner-Approval'},
+      ] as unknown as SubmitRequirementResultInfo[],
+    };
+
+    test('shouldHide - should be `true` when user is not change owner', () => {
       const anonymous = UserRole.ANONYMOUS;
       assert.equal(
-        shouldHide(changeWithSubmitRequirements, patchRange, anonymous),
+        shouldHide(
+          changeWithSubmitRequirements,
+          patchRange,
+          !allFilesApproved,
+          anonymous
+        ),
         true
       );
     });
 
-    test('shouldHide - should be `false` when change has submit requirements and user is logged in', () => {
-      const changeWithSubmitRequirements = {
-        ...change,
-        submit_requirements: [
-          {name: 'Owner-Approval'},
-        ] as unknown as SubmitRequirementResultInfo[],
-      };
+    test('shouldHide - should be `true` when change has submit requirements and has all files approved even if user is logged in', () => {
       assert.equal(
-        shouldHide(changeWithSubmitRequirements, patchRange, loggedIn),
+        shouldHide(
+          changeWithSubmitRequirements,
+          patchRange,
+          allFilesApproved,
+          loggedIn
+        ),
+        true
+      );
+    });
+
+    test('shouldHide - should be `false` when change has submit requirements, has no all files approved and user is logged in', () => {
+      assert.equal(
+        shouldHide(
+          changeWithSubmitRequirements,
+          patchRange,
+          !allFilesApproved,
+          loggedIn
+        ),
         false
       );
     });
@@ -134,11 +184,69 @@
     test('shouldHide - should be `false` when in edit mode', () => {
       const patchRangeWithoutPatchNum = {} as unknown as PatchRange;
       assert.equal(
-        shouldHide(change, patchRangeWithoutPatchNum, loggedIn),
+        shouldHide(
+          change,
+          patchRangeWithoutPatchNum,
+          allFilesApproved,
+          loggedIn
+        ),
         false
       );
     });
   });
+
+  suite('getFileOwnership tests', () => {
+    const path = 'readme.md';
+    const emptyFilesOwners = {} as unknown as FilesOwners;
+    const fileOwnersWithPath = {
+      files: {[path]: [{name: 'John', id: 1}]},
+    } as unknown as FilesOwners;
+
+    test('getFileOwnership - should be `undefined` when path is `undefined', () => {
+      const undefinedPath = undefined;
+      assert.equal(
+        getFileOwnership(undefinedPath, allFilesApproved, emptyFilesOwners),
+        undefined
+      );
+    });
+
+    test('getFileOwnership - should be `undefined` when file owners are `undefined', () => {
+      const undefinedFileOwners = undefined;
+      assert.equal(
+        getFileOwnership(path, allFilesApproved, undefinedFileOwners),
+        undefined
+      );
+    });
+
+    test('getFileOwnership - should return `FileOwnership` with `NOT_OWNED_OR_APPROVED` fileStatus when `allFilesApproved`', () => {
+      assert.equal(
+        deepEqual(
+          getFileOwnership(path, allFilesApproved, fileOwnersWithPath),
+          {fileStatus: FileStatus.NOT_OWNED_OR_APPROVED} as FileOwnership
+        ),
+        true
+      );
+    });
+
+    test('getFileOwnership - should return `FileOwnership` with `NOT_OWNED_OR_APPROVED` fileStatus when file has no owner', () => {
+      assert.equal(
+        deepEqual(getFileOwnership(path, !allFilesApproved, emptyFilesOwners), {
+          fileStatus: FileStatus.NOT_OWNED_OR_APPROVED,
+        } as FileOwnership),
+        true
+      );
+    });
+
+    test('getFileOwnership - should return `FileOwnership` with `NEEDS_APPROVAL` fileStatus when file has owner', () => {
+      assert.equal(
+        deepEqual(
+          getFileOwnership(path, !allFilesApproved, fileOwnersWithPath),
+          {fileStatus: FileStatus.NEEDS_APPROVAL} as FileOwnership
+        ),
+        true
+      );
+    });
+  });
 });
 
 function getRandom<T>(...values: T[]): T {
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
index 61ced72..d1ecaab 100644
--- a/owners/web/owners-model.ts
+++ b/owners/web/owners-model.ts
@@ -41,6 +41,23 @@
   filesOwners?: FilesOwners;
 }
 
+/**
+ * TODO: The plugin's REST endpoint returns only files that still need to be reviewed which means that file can only have two states:
+ * * needs approval
+ * * was not subject of OWNERS file or is already approved
+ */
+export enum FileStatus {
+  NEEDS_APPROVAL = 'NEEDS_APPROVAL',
+  NOT_OWNED_OR_APPROVED = 'NOT_OWNED_OR_APPROVED',
+}
+
+/**
+ * TODO: extend FileOwnership with owners when it will be used by UI elements
+ */
+export interface FileOwnership {
+  fileStatus: FileStatus;
+}
+
 let ownersModel: OwnersModel | undefined;
 
 export class OwnersModel extends EventTarget {