Enable bulk abandon button if change is abandoned

In case user selects an already abandoned change, then let the button
remain enabled and resolve the promise as successful immediately.

Release-Notes: skip
Change-Id: I8434f0c265b6513683240df88dcb94be273fdb40
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index e6e9e08..dc67c1b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -8,7 +8,7 @@
 import {LitElement, html, css} from 'lit';
 import {resolve} from '../../../models/dependency';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
-import {NumericChangeId, ChangeInfo} from '../../../api/rest-api';
+import {NumericChangeId, ChangeInfo, ChangeStatus} from '../../../api/rest-api';
 import {subscribe} from '../../lit/subscription-controller';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {ProgressStatus} from '../../../constants/constants';
@@ -96,8 +96,7 @@
   private isEnabled() {
     return this.selectedChanges.every(
       change =>
-        // TODO: Add handling for when change is already abandoned
-        !!change.actions?.abandon
+        !!change.actions?.abandon || change.status === ChangeStatus.ABANDONED
     );
   }
 
@@ -108,18 +107,20 @@
     }
     this.requestUpdate();
     const errFn = (changeNum: NumericChangeId) => {
-      this.progress.set(changeNum, ProgressStatus.FAILED);
-      this.requestUpdate();
+      throw new Error(`request for ${changeNum} failed`);
     };
     const promises = this.getBulkActionsModel().abandonChanges('', errFn);
     for (let index = 0; index < promises.length; index++) {
-      promises[index].then(result => {
-        const changeNum = this.selectedChanges[index]._number;
-        if (result?.status !== 200)
+      const changeNum = this.selectedChanges[index]._number;
+      promises[index]
+        .then(() => {
+          this.progress.set(changeNum, ProgressStatus.SUCCESSFUL);
+          this.requestUpdate();
+        })
+        .catch(() => {
           this.progress.set(changeNum, ProgressStatus.FAILED);
-        else this.progress.set(changeNum, ProgressStatus.SUCCESSFUL);
-        this.requestUpdate();
-      });
+          this.requestUpdate();
+        });
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index b678f6f..ad7281b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -5,7 +5,13 @@
  */
 
 import {createChange} from '../../../test/test-data-generators';
-import {NumericChangeId, ChangeInfo} from '../../../api/rest-api';
+import {
+  NumericChangeId,
+  ChangeInfo,
+  ChangeStatus,
+  HttpMethod,
+  PatchSetNum,
+} from '../../../api/rest-api';
 import {GrChangeListBulkAbandonFlow} from './gr-change-list-bulk-abandon-flow';
 import '../../../test/common-test-setup-karma';
 import {
@@ -28,6 +34,8 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {ProgressStatus} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+import {ErrorCallback} from '../../../api/rest';
 
 const change1: ChangeInfo = {...createChange(), _number: 1 as NumericChangeId};
 const change2: ChangeInfo = {...createChange(), _number: 2 as NumericChangeId};
@@ -89,6 +97,32 @@
     assert.isTrue(queryAndAssert<GrButton>(element, '#abandon').disabled);
   });
 
+  test('abandon button is enabled if change is already abandoned', async () => {
+    const changes: ChangeInfo[] = [
+      {...change1, actions: {}, status: ChangeStatus.ABANDONED},
+    ];
+    getChangesStub.returns(changes);
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await flush();
+    assert.isFalse(queryAndAssert<GrButton>(element, '#abandon').disabled);
+
+    tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
+
+    await waitUntil(
+      () =>
+        queryAndAssert<HTMLTableDataCellElement>(
+          element,
+          '#status'
+        ).innerText.trim() === `Status: ${ProgressStatus.SUCCESSFUL}`
+    );
+  });
+
   test('progress updates as request is resolved', async () => {
     const changes: ChangeInfo[] = [{...change1, actions: {abandon: {}}}];
     getChangesStub.returns(changes);
@@ -156,8 +190,20 @@
       `Status: ${ProgressStatus.NOT_STARTED}`
     );
 
-    const executeChangeAction = mockPromise<Response>();
-    stubRestApi('executeChangeAction').returns(executeChangeAction);
+    stubRestApi('executeChangeAction').callsFake(
+      (
+        _changeNum: NumericChangeId,
+        _method: HttpMethod | undefined,
+        _endpoint: string,
+        _patchNum?: PatchSetNum,
+        _payload?: RequestPayload,
+        errFn?: ErrorCallback
+      ) =>
+        Promise.resolve(new Response()).then(res => {
+          errFn && errFn();
+          return res;
+        })
+    );
 
     tap(queryAndAssert(query(element, 'gr-dialog'), '#confirm'));
     await element.updateComplete;
@@ -170,7 +216,6 @@
       `Status: ${ProgressStatus.RUNNING}`
     );
 
-    executeChangeAction.resolve({...new Response(), status: 500});
     await waitUntil(
       () => element.progress.get(1 as NumericChangeId) === ProgressStatus.FAILED
     );
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index d483803..5ab1617 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {ChangeInfo, NumericChangeId} from '../../api/rest-api';
+import {ChangeInfo, NumericChangeId, ChangeStatus} from '../../api/rest-api';
 import {Model} from '../model';
 import {Finalizable} from '../../services/registry';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
@@ -108,6 +108,9 @@
       if (!current.allChanges.get(changeNum))
         throw new Error('invalid change id');
       const change = current.allChanges.get(changeNum)!;
+      if (change.status === ChangeStatus.ABANDONED) {
+        return Promise.resolve(new Response());
+      }
       return this.restApiService.executeChangeAction(
         change._number,
         change.actions!.abandon!.method,
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index acf0462..a2c4bac 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -5,12 +5,19 @@
  */
 
 import {createChange} from '../../test/test-data-generators';
-import {ChangeInfo, NumericChangeId} from '../../api/rest-api';
+import {
+  ChangeInfo,
+  NumericChangeId,
+  ChangeStatus,
+  HttpMethod,
+} from '../../api/rest-api';
 import {BulkActionsModel, LoadingState} from './bulk-actions-model';
 import {getAppContext} from '../../services/app-context';
 import '../../test/common-test-setup-karma';
 import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
 import {mockPromise} from '../../test/test-utils';
+import {SinonStubbedMember} from 'sinon';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 
 suite('bulk actions model test', () => {
   let bulkActionsModel: BulkActionsModel;
@@ -102,6 +109,44 @@
     assert.equal(totalChangeCount, 2);
   });
 
+  suite('abandon changes', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      const c1 = createChange();
+      c1._number = 1 as NumericChangeId;
+      const c2 = createChange();
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('already abandoned change does not call executeChangeAction', () => {
+      const actionStub = stubRestApi('executeChangeAction');
+      bulkActionsModel.abandonChanges();
+      assert.equal(actionStub.callCount, 1);
+      assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [
+        1 as NumericChangeId,
+        HttpMethod.POST,
+        '/abandon',
+        undefined,
+        {message: ''},
+      ]);
+    });
+  });
+
   test('stale changes are removed from the model', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;