Implement flows-model similar to checks-model

Will be used in CreateFlow in a follow up CL.

Bug: Google b/431939712
Release-Notes: skip
Change-Id: I436230ec78a856f5d86385a1a55c7031c3cfcb6e
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
index cc523cc..f2f93d0 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
@@ -11,8 +11,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {FlowInfo, FlowStageState} from '../../../api/rest-api';
-
-import {getAppContext} from '../../../services/app-context';
+import {flowsModelToken} from '../../../models/flows/flows-model';
 import {NumericChangeId} from '../../../types/common';
 import './gr-create-flow';
 import {when} from 'lit/directives/when.js';
@@ -52,7 +51,7 @@
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
-  private readonly restApiService = getAppContext().restApiService;
+  private readonly getFlowsModel = resolve(this, flowsModelToken);
 
   static override get styles() {
     return [
@@ -136,23 +135,27 @@
       () => this.getChangeModel().changeNum$,
       changeNum => {
         this.changeNum = changeNum;
-        this.loadFlows();
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFlowsModel().flows$,
+      flows => {
+        this.flows = flows;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getFlowsModel().loading$,
+      loading => {
+        this.loading = loading;
       }
     );
   }
 
-  async loadFlows() {
-    if (!this.changeNum) return;
-    this.loading = true;
-    const flows = await this.restApiService.listFlows(this.changeNum);
-    this.flows = flows ?? [];
-    this.loading = false;
-  }
-
   private async deleteFlow() {
-    if (!this.changeNum || !this.flowIdToDelete) return;
-    await this.restApiService.deleteFlow(this.changeNum, this.flowIdToDelete);
-    await this.loadFlows();
+    if (!this.flowIdToDelete) return;
+    await this.getFlowsModel().deleteFlow(this.flowIdToDelete);
     this.closeConfirmDialog();
   }
 
@@ -172,7 +175,7 @@
         <h2 class="main-heading">Create new flow</h2>
         <gr-create-flow
           .changeNum=${this.changeNum}
-          @flow-created=${this.loadFlows}
+          @flow-created=${() => this.getFlowsModel().reload()}
         ></gr-create-flow>
         <hr />
         ${this.renderFlowsList()}
@@ -226,7 +229,7 @@
           <h2 class="main-heading">Existing Flows</h2>
           <gr-button
             link
-            @click=${this.loadFlows}
+            @click=${() => this.getFlowsModel().reload()}
             aria-label="Refresh flows"
             title="Refresh flows"
             class="refresh"
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
index 62534de..212bf1d 100644
--- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
@@ -8,19 +8,28 @@
 import {assert, fixture, html} from '@open-wc/testing';
 import {GrFlows} from './gr-flows';
 import {FlowInfo, FlowStageState, Timestamp} from '../../../api/rest-api';
-import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert} from '../../../test/test-utils';
 import {NumericChangeId} from '../../../types/common';
 import {GrCreateFlow} from './gr-create-flow';
 import sinon from 'sinon';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-flows tests', () => {
   let element: GrFlows;
   let clock: sinon.SinonFakeTimers;
+  let flowsModel: FlowsModel;
 
   setup(async () => {
     clock = sinon.useFakeTimers();
+
+    flowsModel = testResolver(flowsModelToken);
+    // The model is created by the DI system. The test setup replaces the real
+    // model with a mock. To prevent real API calls, we stub the reload method.
+    sinon.stub(flowsModel, 'reload');
+
     element = await fixture<GrFlows>(html`<gr-flows></gr-flows>`);
     element['changeNum'] = 123 as NumericChangeId;
     await element.updateComplete;
@@ -31,8 +40,7 @@
   });
 
   test('renders create flow component and no flows', async () => {
-    stubRestApi('listFlows').returns(Promise.resolve([]));
-    await element['loadFlows']();
+    flowsModel.setState({flows: [], loading: false});
     await element.updateComplete;
     assert.shadowDom.equal(
       element,
@@ -85,8 +93,7 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    await element['loadFlows']();
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     // prettier formats the spacing for "last evaluated" incorrectly
@@ -242,11 +249,8 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    const deleteFlowStub = sinon
-      .stub(element['restApiService'], 'deleteFlow')
-      .returns(Promise.resolve(new Response()));
-    await element['loadFlows']();
+    const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button');
@@ -264,7 +268,7 @@
     confirmButton.click();
     await element.updateComplete;
 
-    assert.isTrue(deleteFlowStub.calledOnceWith(123, 'flow1'));
+    assert.isTrue(deleteFlowStub.calledOnceWith('flow1'));
   });
 
   test('cancel deleting a flow', async () => {
@@ -281,11 +285,8 @@
         ],
       },
     ];
-    stubRestApi('listFlows').returns(Promise.resolve(flows));
-    const deleteFlowStub = sinon
-      .stub(element['restApiService'], 'deleteFlow')
-      .returns(Promise.resolve(new Response()));
-    await element['loadFlows']();
+    const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow');
+    flowsModel.setState({flows, loading: false});
     await element.updateComplete;
 
     const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button');
@@ -308,11 +309,8 @@
   });
 
   test('reloads flows on flow-created event', async () => {
-    const listFlowsStub = stubRestApi('listFlows').returns(Promise.resolve([]));
-    await element['loadFlows']();
-    await element.updateComplete;
-
-    assert.isTrue(listFlowsStub.calledOnce);
+    const reloadStub = flowsModel.reload as sinon.SinonStub;
+    reloadStub.resetHistory();
 
     const createFlow = queryAndAssert<GrCreateFlow>(element, 'gr-create-flow');
     createFlow.dispatchEvent(
@@ -321,30 +319,22 @@
 
     await element.updateComplete;
 
-    assert.isTrue(listFlowsStub.calledTwice);
+    assert.isTrue(reloadStub.calledOnce);
   });
 
   test('refreshes flows on button click', async () => {
-    const flows: FlowInfo[] = [
-      {
-        uuid: 'flow1',
-        owner: {name: 'owner1'},
-        created: '2025-01-01T10:00:00.000Z' as Timestamp,
-        stages: [
-          {
-            expression: {condition: 'label:Code-Review=+1'},
-            state: FlowStageState.DONE,
-          },
-        ],
-      },
-    ];
-    const listFlowsStub = stubRestApi('listFlows').returns(
-      Promise.resolve(flows)
-    );
-    await element.loadFlows();
+    const flow = {
+      uuid: 'flow1',
+      owner: {name: 'owner1'},
+      created: '2025-01-01T10:00:00.000Z' as Timestamp,
+      stages: [],
+    } as FlowInfo;
+    flowsModel.setState({flows: [flow], loading: false});
     await element.updateComplete;
 
-    assert.isTrue(listFlowsStub.calledOnce);
+    const reloadStub = flowsModel.reload as sinon.SinonStub;
+    reloadStub.resetHistory();
+
     const refreshButton = queryAndAssert<GrButton>(
       element,
       '.heading-with-button gr-button'
@@ -352,7 +342,7 @@
     refreshButton.click();
     await element.updateComplete;
 
-    assert.isTrue(listFlowsStub.calledTwice);
+    assert.isTrue(reloadStub.calledOnce);
   });
 
   suite('filter', () => {
@@ -401,8 +391,7 @@
     ];
 
     setup(async () => {
-      stubRestApi('listFlows').returns(Promise.resolve(flows));
-      await element.loadFlows();
+      flowsModel.setState({flows, loading: false});
       await element.updateComplete;
     });
 
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts
new file mode 100644
index 0000000..31ecd58
--- /dev/null
+++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BehaviorSubject, combineLatest, from, of} from 'rxjs';
+import {catchError, map, switchMap} from 'rxjs/operators';
+import {ChangeModel} from '../change/change-model';
+import {FlowInfo} from '../../api/rest-api';
+import {Model} from '../base/model';
+import {define} from '../dependency';
+
+import {NumericChangeId} from '../../types/common';
+import {getAppContext} from '../../services/app-context';
+
+export interface FlowsState {
+  flows: FlowInfo[];
+  loading: boolean;
+  errorMessage?: string;
+}
+
+export const flowsModelToken = define<FlowsModel>('flows-model');
+
+export class FlowsModel extends Model<FlowsState> {
+  readonly flows$ = this.state$.pipe(map(s => s.flows));
+
+  readonly loading$ = this.state$.pipe(map(s => s.loading));
+
+  private readonly reload$ = new BehaviorSubject<void>(undefined);
+
+  private changeNum?: NumericChangeId;
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  constructor(private readonly changeModel: ChangeModel) {
+    super({
+      flows: [],
+      loading: true,
+    });
+
+    this.subscriptions.push(
+      this.changeModel.changeNum$.subscribe(changeNum => {
+        this.changeNum = changeNum;
+      })
+    );
+
+    this.subscriptions.push(
+      combineLatest([this.changeModel.changeNum$, this.reload$])
+        .pipe(
+          switchMap(([changeNum]) => {
+            if (!changeNum) return of([]);
+            this.setState({...this.getState(), loading: true});
+            return from(this.restApiService.listFlows(changeNum)).pipe(
+              catchError(err => {
+                this.setState({
+                  ...this.getState(),
+                  errorMessage: `Failed to load flows: ${err}`,
+                  loading: false,
+                });
+                return of([]);
+              })
+            );
+          })
+        )
+        .subscribe(flows => {
+          this.setState({
+            ...this.getState(),
+            flows: flows ?? [],
+            loading: false,
+          });
+        })
+    );
+  }
+
+  reload() {
+    this.reload$.next();
+  }
+
+  async deleteFlow(flowId: string) {
+    if (!this.changeNum) return;
+    await this.restApiService.deleteFlow(this.changeNum, flowId);
+    this.reload();
+  }
+}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ab2ae52..24d37a8 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -75,6 +75,7 @@
 import {Finalizable} from '../types/types';
 import {GrSuggestionsService} from './suggestions/suggestions-service_impl';
 import {suggestionsServiceToken} from './suggestions/suggestions-service';
+import {FlowsModel, flowsModelToken} from '../models/flows/flows-model';
 /**
  * The AppContext lazy initializator for all services
  */
@@ -249,5 +250,6 @@
           resolver(changeModelToken)
         ),
     ],
+    [flowsModelToken, () => new FlowsModel(resolver(changeModelToken))],
   ]);
 }