| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import { |
| ChangeInfo, |
| NumericChangeId, |
| ChangeStatus, |
| ReviewerState, |
| AccountId, |
| AccountInfo, |
| GroupInfo, |
| Hashtag, |
| } from '../../api/rest-api'; |
| import {Model} from '../model'; |
| import {Finalizable} from '../../services/registry'; |
| import {RestApiService} from '../../services/gr-rest-api/gr-rest-api'; |
| import {define} from '../dependency'; |
| import {select} from '../../utils/observable-util'; |
| import { |
| ReviewInput, |
| ReviewerInput, |
| AttentionSetInput, |
| RelatedChangeAndCommitInfo, |
| } from '../../types/common'; |
| import {getUserId} from '../../utils/account-util'; |
| import {getChangeNumber} from '../../utils/change-util'; |
| import {deepEqual} from '../../utils/deep-util'; |
| |
| export const bulkActionsModelToken = |
| define<BulkActionsModel>('bulk-actions-model'); |
| |
| export enum LoadingState { |
| NOT_SYNCED = 'NOT_SYNCED', |
| LOADING = 'LOADING', |
| LOADED = 'LOADED', |
| } |
| export interface BulkActionsState { |
| loadingState: LoadingState; |
| selectableChangeNums: NumericChangeId[]; |
| selectedChangeNums: NumericChangeId[]; |
| allChanges: Map<NumericChangeId, ChangeInfo>; |
| } |
| |
| const initialState: BulkActionsState = { |
| loadingState: LoadingState.NOT_SYNCED, |
| selectedChangeNums: [], |
| selectableChangeNums: [], |
| allChanges: new Map(), |
| }; |
| |
| export class BulkActionsModel |
| extends Model<BulkActionsState> |
| implements Finalizable |
| { |
| constructor(private readonly restApiService: RestApiService) { |
| super(initialState); |
| } |
| |
| public readonly selectedChangeNums$ = select( |
| this.state$, |
| bulkActionsState => bulkActionsState.selectedChangeNums |
| ); |
| |
| public readonly totalChangeCount$ = select( |
| this.state$, |
| bulkActionsState => bulkActionsState.allChanges.size |
| ); |
| |
| public readonly loadingState$ = select( |
| this.state$, |
| bulkActionsState => bulkActionsState.loadingState |
| ); |
| |
| public readonly selectedChanges$ = select(this.state$, bulkActionsState => { |
| const result = []; |
| for (const changeNum of bulkActionsState.selectedChangeNums) { |
| const change = bulkActionsState.allChanges.get(changeNum); |
| if (change) result.push(change); |
| } |
| return result; |
| }); |
| |
| toggleSelectedChangeNum(changeNum: NumericChangeId) { |
| this.getState().selectedChangeNums.includes(changeNum) |
| ? this.removeSelectedChangeNum(changeNum) |
| : this.addSelectedChangeNum(changeNum); |
| } |
| |
| addSelectedChangeNum(changeNum: NumericChangeId) { |
| const current = this.getState(); |
| if (!current.selectableChangeNums.includes(changeNum)) { |
| throw new Error( |
| `Trying to add change ${changeNum} that is not part of bulk-actions model` |
| ); |
| } |
| const selectedChangeNums = [...current.selectedChangeNums]; |
| selectedChangeNums.push(changeNum); |
| this.setState({...current, selectedChangeNums}); |
| } |
| |
| removeSelectedChangeNum(changeNum: NumericChangeId) { |
| const current = this.getState(); |
| if (!current.selectableChangeNums.includes(changeNum)) { |
| throw new Error( |
| `Trying to remove change ${changeNum} that is not part of bulk-actions model` |
| ); |
| } |
| const selectedChangeNums = [...current.selectedChangeNums]; |
| const index = selectedChangeNums.findIndex(item => item === changeNum); |
| if (index === -1) return; |
| selectedChangeNums.splice(index, 1); |
| this.updateState({selectedChangeNums}); |
| } |
| |
| clearSelectedChangeNums() { |
| this.updateState({selectedChangeNums: []}); |
| } |
| |
| selectAll() { |
| const current = this.getState(); |
| this.updateState({ |
| selectedChangeNums: Array.from(current.allChanges.keys()), |
| }); |
| } |
| |
| abandonChanges( |
| reason?: string, |
| // errorFn is needed to avoid showing an error dialog |
| errFn?: (changeNum: NumericChangeId) => void |
| ): Promise<Response | undefined>[] { |
| const current = this.getState(); |
| return current.selectedChangeNums.map(changeNum => { |
| 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( |
| getChangeNumber(change), |
| change.actions!.abandon!.method, |
| '/abandon', |
| undefined, |
| {message: reason ?? ''}, |
| () => errFn && errFn(getChangeNumber(change)) |
| ); |
| }); |
| } |
| |
| voteChanges(reviewInput: ReviewInput) { |
| const current = this.getState(); |
| return current.selectedChangeNums.map(changeNum => { |
| const change = current.allChanges.get(changeNum)!; |
| if (!change) throw new Error('invalid change id'); |
| return this.restApiService.saveChangeReview( |
| getChangeNumber(change), |
| 'current', |
| reviewInput, |
| () => { |
| throw new Error(); |
| } |
| ); |
| }); |
| } |
| |
| addReviewers( |
| changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>, |
| reason: string |
| ): Promise<Response>[] { |
| const current = this.getState(); |
| const changes = current.selectedChangeNums.map( |
| changeNum => current.allChanges.get(changeNum)! |
| ); |
| return changes.map(change => { |
| const reviewersNewToChange: ReviewerInput[] = [ |
| ReviewerState.REVIEWER, |
| ReviewerState.CC, |
| ].flatMap(state => |
| this.getNewReviewersToChange(change, state, changedReviewers) |
| ); |
| if (reviewersNewToChange.length === 0) { |
| return Promise.resolve(new Response()); |
| } |
| const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange |
| .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER) |
| .map(reviewerInput => { |
| return { |
| // TODO: Once Groups are supported, filter them out and only add |
| // Accounts to the attention set, just like gr-reply-dialog. |
| user: reviewerInput.reviewer as AccountId, |
| reason, |
| }; |
| }); |
| const reviewInput: ReviewInput = { |
| reviewers: reviewersNewToChange, |
| ignore_automatic_attention_set_rules: true, |
| add_to_attention_set: attentionSetUpdates, |
| }; |
| return this.restApiService.saveChangeReview( |
| getChangeNumber(change), |
| 'current', |
| reviewInput |
| ); |
| }); |
| } |
| |
| addHashtags(hashtags: Hashtag[]): Promise<Hashtag[]>[] { |
| const current = this.getState(); |
| return current.selectedChangeNums.map(changeNum => |
| this.restApiService |
| .setChangeHashtag(changeNum, { |
| add: hashtags, |
| }) |
| .then(responseHashtags => { |
| // Once we get server confirmation that the hashtags were added to the |
| // change, we are updating the model's ChangeInfo. This way we can |
| // keep the page state (dialog status) but use the updated change info |
| // naturally. |
| |
| // refetch the current state since other changes may have been updated |
| // since the promises were launched. |
| const current = this.getState(); |
| const nextState = { |
| ...current, |
| allChanges: new Map(current.allChanges), |
| }; |
| nextState.allChanges.set(changeNum, { |
| ...nextState.allChanges.get(changeNum)!, |
| hashtags: responseHashtags, |
| }); |
| this.setState(nextState); |
| return responseHashtags; |
| }) |
| ); |
| } |
| |
| async sync(changes: (ChangeInfo | RelatedChangeAndCommitInfo)[]) { |
| const basicChanges = new Map(changes.map(c => [getChangeNumber(c), c])); |
| let currentState = this.getState(); |
| const selectedChangeNums = currentState.selectedChangeNums.filter( |
| changeNum => basicChanges.has(changeNum) |
| ); |
| const selectableChangeNums = changes.map(c => getChangeNumber(c)); |
| this.updateState({ |
| loadingState: LoadingState.LOADING, |
| selectedChangeNums, |
| selectableChangeNums, |
| allChanges: new Map(), |
| }); |
| |
| if (changes.length === 0) { |
| return; |
| } |
| const changeDetails = |
| await this.restApiService.getDetailedChangesWithActions( |
| changes.map(c => getChangeNumber(c)) |
| ); |
| currentState = this.getState(); |
| // Return early if sync has been called again since starting the load. |
| if (!deepEqual(selectableChangeNums, currentState.selectableChangeNums)) { |
| return; |
| } |
| const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map(); |
| for (const detailedChange of changeDetails ?? []) { |
| allDetailedChanges.set(detailedChange._number, detailedChange); |
| } |
| this.setState({ |
| ...currentState, |
| loadingState: LoadingState.LOADED, |
| allChanges: allDetailedChanges, |
| }); |
| } |
| |
| private getNewReviewersToChange( |
| change: ChangeInfo, |
| state: ReviewerState, |
| changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]> |
| ): ReviewerInput[] { |
| return ( |
| changedReviewers |
| .get(state) |
| ?.filter(account => !change.reviewers[state]?.includes(account)) |
| .map(account => { |
| return {state, reviewer: getUserId(account)}; |
| }) ?? [] |
| ); |
| } |
| } |