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))],
]);
}