Introduce `allFilesApproved` and `filesOwners` states to plugin UI

The `OwnersModel` was extended with this two states. They are loaded
reactively from plugin through the `OwnersService`. Both header and
content UI elements subscribe on these states updates and basing on that
update their corresponding internal states. As a result `render` method
is called for each element. At the moment values of `allFilesApproved`
and `filesOwners` are logged to the console.

Notes:
* UI elements have `change` property that gets updated by Gerrit UI when
  update is detected while details screen is opened, as a result new
  model and model loader are created, UI elements subscriptions are
  refreshed and new state values are loaded, leading eventually to
  reactive UI refresh
* despite `allFilesApproved` logic that prevents from unecessary calls
  to obtain file owners it is still called for each file (as they form
  independent UI elements). It will be further improved (in a later
  follow up change) with an introducetion of cache (between service and
  plugin api).

Change-Id: I1ac079a293033797c3b8dd1c06581d8a0c10fba7
diff --git a/owners/web/gr-owners.ts b/owners/web/gr-owners.ts
index 76b89d9..85e92dc 100644
--- a/owners/web/gr-owners.ts
+++ b/owners/web/gr-owners.ts
@@ -22,7 +22,11 @@
   ChangeInfo,
   ChangeStatus,
 } from '@gerritcodereview/typescript-api/rest-api';
-import {OWNERS_SUBMIT_REQUIREMENT, OwnersService} from './owners-service';
+import {
+  FilesOwners,
+  OWNERS_SUBMIT_REQUIREMENT,
+  OwnersService,
+} from './owners-service';
 import {RestPluginApi} from '@gerritcodereview/typescript-api/rest';
 import {ModelLoader, OwnersModel, PatchRange, UserRole} from './owners-model';
 
@@ -35,6 +39,8 @@
   patchRange?: PatchRange;
   restApi?: RestPluginApi;
   userRole?: UserRole;
+  allFilesApproved?: boolean;
+  filesOwners?: FilesOwners;
 
   onModelUpdate(): void;
 }
@@ -53,6 +59,12 @@
     @state()
     userRole?: UserRole;
 
+    @state()
+    allFilesApproved?: boolean;
+
+    @state()
+    filesOwners?: FilesOwners;
+
     private _model?: OwnersModel;
 
     modelLoader?: ModelLoader;
@@ -78,6 +90,18 @@
         })
       );
 
+      this.subscriptions.push(
+        model.state$.subscribe(s => {
+          this.allFilesApproved = s.allFilesApproved;
+        })
+      );
+
+      this.subscriptions.push(
+        model.state$.subscribe(s => {
+          this.filesOwners = s.filesOwners;
+        })
+      );
+
       this.onModelUpdate();
     }
 
@@ -104,6 +128,8 @@
 
     protected onModelUpdate() {
       this.modelLoader?.loadUserRole();
+      this.modelLoader?.loadAllFilesApproved();
+      this.modelLoader?.loadFilesOwners();
     }
 
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -126,11 +152,7 @@
 
   override render() {
     console.log(
-      `hidden: ${shouldHide(
-        this.change,
-        this.patchRange,
-        this.userRole
-      )}, userRole: ${this.userRole}`
+      `hidden: ${this.hidden}, userRole: ${this.userRole}, allFilesApproved: ${this.allFilesApproved}`
     );
     return nothing;
   }
@@ -151,7 +173,11 @@
 
   override render() {
     console.log(
-      `hidden: ${this.hidden}, userRole: ${this.userRole}, path: ${this.path}, oldPath: ${this.oldPath}`
+      `hidden: ${this.hidden}, userRole: ${this.userRole}, path: ${
+        this.path
+      }, oldPath: ${this.oldPath}, filesOwners: ${JSON.stringify(
+        this.filesOwners
+      )}`
     );
     return nothing;
   }
diff --git a/owners/web/owners-model.ts b/owners/web/owners-model.ts
index 952fa3f..61ced72 100644
--- a/owners/web/owners-model.ts
+++ b/owners/web/owners-model.ts
@@ -21,7 +21,8 @@
   ChangeInfo,
   RevisionPatchSetNum,
 } from '@gerritcodereview/typescript-api/rest-api';
-import {OwnersService} from './owners-service';
+import {FilesOwners, OwnersService} from './owners-service';
+import {deepEqual} from './utils';
 
 export interface PatchRange {
   patchNum: RevisionPatchSetNum;
@@ -36,6 +37,8 @@
 
 export interface OwnersState {
   userRole?: UserRole;
+  allFilesApproved?: boolean;
+  filesOwners?: FilesOwners;
 }
 
 let ownersModel: OwnersModel | undefined;
@@ -65,6 +68,18 @@
     this.setState({...current, userRole});
   }
 
+  setAllFilesApproved(allFilesApproved: boolean | undefined) {
+    const current = this.subject$.getValue();
+    if (current.allFilesApproved === allFilesApproved) return;
+    this.setState({...current, allFilesApproved});
+  }
+
+  setFilesOwners(filesOwners: FilesOwners | undefined) {
+    const current = this.subject$.getValue();
+    if (deepEqual(current.filesOwners, filesOwners)) return;
+    this.setState({...current, filesOwners});
+  }
+
   static getModel(change: ChangeInfo) {
     if (!ownersModel || ownersModel.change !== change) {
       ownersModel = new OwnersModel(change);
@@ -87,6 +102,22 @@
     );
   }
 
+  async loadAllFilesApproved() {
+    await this._loadProperty(
+      'allFilesApproved',
+      () => this.service.getAllFilesApproved(),
+      value => this.model.setAllFilesApproved(value)
+    );
+  }
+
+  async loadFilesOwners() {
+    await this._loadProperty(
+      'filesOwners',
+      () => this.service.getFilesOwners(),
+      value => this.model.setFilesOwners(value)
+    );
+  }
+
   private async _loadProperty<K extends keyof OwnersState, T>(
     propertyName: K,
     propertyLoader: () => Promise<T>,