| /** |
| * @license |
| * Copyright 2015 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; |
| import '../../plugins/gr-endpoint-param/gr-endpoint-param'; |
| import '../../plugins/gr-endpoint-slot/gr-endpoint-slot'; |
| import '../../shared/gr-account-chip/gr-account-chip'; |
| import '../../shared/gr-button/gr-button'; |
| import '../../shared/gr-icon/gr-icon'; |
| import '../../shared/gr-formatted-text/gr-formatted-text'; |
| import '../../shared/gr-account-list/gr-account-list'; |
| import '../gr-label-scores/gr-label-scores'; |
| import '../gr-thread-list/gr-thread-list'; |
| import '../../../styles/shared-styles'; |
| import {GrReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider'; |
| import {getAppContext} from '../../../services/app-context'; |
| import { |
| ChangeStatus, |
| DraftsAction, |
| ReviewerState, |
| SpecialFilePath, |
| } from '../../../constants/constants'; |
| |
| import { |
| AccountInfoInput, |
| AccountInput, |
| AccountInputDetail, |
| getUserId, |
| GroupInfoInput, |
| isAccountNewlyAdded, |
| RawAccountInput, |
| removeServiceUsers, |
| toReviewInput, |
| } from '../../../utils/account-util'; |
| import {TargetElement} from '../../../api/plugin'; |
| import {isDefined, ParsedChangeInfo} from '../../../types/types'; |
| import {GrAccountList} from '../../shared/gr-account-list/gr-account-list'; |
| import { |
| AccountId, |
| AccountInfo, |
| AttentionSetInput, |
| ChangeInfo, |
| CommentThread, |
| DraftInfo, |
| GroupInfo, |
| isAccount, |
| isDetailedLabelInfo, |
| isReviewerAccountSuggestion, |
| isReviewerGroupSuggestion, |
| ReviewerInput, |
| ReviewInput, |
| ReviewResult, |
| ServerInfo, |
| SuggestedReviewerGroupInfo, |
| Suggestion, |
| UserId, |
| isDraft, |
| ChangeViewChangeInfo, |
| } from '../../../types/common'; |
| import {GrButton} from '../../shared/gr-button/gr-button'; |
| import {GrLabelScores} from '../gr-label-scores/gr-label-scores'; |
| import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row'; |
| import { |
| areSetsEqual, |
| assertIsDefined, |
| containsAll, |
| difference, |
| queryAndAssert, |
| } from '../../../utils/common-util'; |
| import { |
| getFirstComment, |
| isPatchsetLevel, |
| isUnresolved, |
| } from '../../../utils/comment-util'; |
| import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip'; |
| import { |
| getApprovalInfo, |
| getMaxAccounts, |
| StandardLabels, |
| } from '../../../utils/label-util'; |
| import {pluralize} from '../../../utils/string-util'; |
| import { |
| fireAlert, |
| fireError, |
| fire, |
| fireNoBubble, |
| fireIronAnnounce, |
| fireServerError, |
| fireReload, |
| } from '../../../utils/event-util'; |
| import {ErrorCallback} from '../../../api/rest'; |
| import {DelayedTask} from '../../../utils/async-util'; |
| import {Interaction, Timing} from '../../../constants/reporting'; |
| import { |
| getMentionedReason, |
| getReplyByReason, |
| } from '../../../utils/attention-set-util'; |
| import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api'; |
| import {resolve} from '../../../models/dependency'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {LabelNameToValuesMap, PatchSetNumber} from '../../../api/rest-api'; |
| import {css, html, PropertyValues, LitElement, nothing} from 'lit'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {when} from 'lit/directives/when.js'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import { |
| AddReviewerEvent, |
| RemoveReviewerEvent, |
| ValueChangedEvent, |
| } from '../../../types/events'; |
| import {customElement, property, state, query} from 'lit/decorators.js'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {configModelToken} from '../../../models/config/config-model'; |
| import {hasHumanReviewer} from '../../../utils/change-util'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import { |
| CommentEditingChangedDetail, |
| GrComment, |
| } from '../../shared/gr-comment/gr-comment'; |
| import {ShortcutController} from '../../lit/shortcut-controller'; |
| import {Key, Modifier, whenVisible} from '../../../utils/dom-util'; |
| import {GrThreadList} from '../gr-thread-list/gr-thread-list'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {accountsModelToken} from '../../../models/accounts/accounts-model'; |
| import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; |
| import {modalStyles} from '../../../styles/gr-modal-styles'; |
| import {ironAnnouncerRequestAvailability} from '../../polymer-util'; |
| import {GrReviewerUpdatesParser} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser'; |
| import {formStyles} from '../../../styles/form-styles'; |
| import {navigationToken} from '../../core/gr-navigation/gr-navigation'; |
| import {getDocUrl} from '../../../utils/url-util'; |
| import { |
| readJSONResponsePayload, |
| ResponsePayload, |
| } from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; |
| |
| export enum FocusTarget { |
| ANY = 'any', |
| BODY = 'body', |
| CCS = 'cc', |
| REVIEWERS = 'reviewers', |
| } |
| |
| enum ReviewerType { |
| REVIEWER = 'REVIEWER', |
| CC = 'CC', |
| } |
| |
| enum LatestPatchState { |
| LATEST = 'latest', |
| CHECKING = 'checking', |
| NOT_LATEST = 'not-latest', |
| } |
| |
| const ButtonLabels = { |
| START_REVIEW: 'Start review', |
| SEND: 'Send', |
| }; |
| |
| const ButtonTooltips = { |
| SAVE: 'Send changes and comments as work in progress but do not start review', |
| START_REVIEW: 'Mark as ready for review and send reply', |
| SEND: 'Send reply', |
| DISABLED_COMMENT_EDITING: 'Save draft comments to enable send', |
| }; |
| |
| const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.'; |
| |
| @customElement('gr-reply-dialog') |
| export class GrReplyDialog extends LitElement { |
| FocusTarget = FocusTarget; |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| private readonly getChangeModel = resolve(this, changeModelToken); |
| |
| private readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| // TODO: update type to only ParsedChangeInfo |
| @property({type: Object}) |
| change?: ParsedChangeInfo | ChangeInfo; |
| |
| @property({type: Boolean}) |
| canBeStarted = false; |
| |
| @property({type: Boolean, reflect: true}) |
| disabled = false; |
| |
| @state() |
| draftCommentThreads: CommentThread[] = []; |
| |
| @property({type: Object}) |
| permittedLabels?: LabelNameToValuesMap; |
| |
| @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment; |
| |
| @query('#reviewers') reviewersList?: GrAccountList; |
| |
| @query('#ccs') ccsList?: GrAccountList; |
| |
| @query('#cancelButton') cancelButton?: GrButton; |
| |
| @query('#sendButton') sendButton?: GrButton; |
| |
| @query('#labelScores') labelScores?: GrLabelScores; |
| |
| @query('#reviewerConfirmationModal') |
| reviewerConfirmationModal?: HTMLDialogElement; |
| |
| @state() latestPatchNum?: PatchSetNumber; |
| |
| @state() serverConfig?: ServerInfo; |
| |
| @state() private docsBaseUrl = ''; |
| |
| @state() |
| patchsetLevelDraftMessage = ''; |
| |
| @state() |
| filterReviewerSuggestion: (input: Suggestion) => boolean; |
| |
| @state() |
| filterCCSuggestion: (input: Suggestion) => boolean; |
| |
| @state() |
| knownLatestState?: LatestPatchState; |
| |
| @state() |
| underReview = true; |
| |
| @state() |
| account?: AccountInfo; |
| |
| get ccs() { |
| return [ |
| ...this._ccs, |
| ...this.mentionedUsers.filter(v => !this.isAlreadyReviewerOrCC(v)), |
| ]; |
| } |
| |
| /** |
| * We pass the ccs object to AccountInput for modifying where it needs to |
| * add a value to CC. The returned value contains both mentionedUsers and |
| * normal ccs hence separate the two when setting ccs. |
| */ |
| set ccs(ccs: AccountInput[]) { |
| this._ccs = ccs.filter( |
| cc => |
| !this.mentionedUsers.some( |
| mentionedCC => getUserId(mentionedCC) === getUserId(cc) |
| ) |
| ); |
| this.requestUpdate('ccs', ccs); |
| } |
| |
| @state() |
| _ccs: AccountInput[] = []; |
| |
| /** |
| * Maintain a separate list of users added to cc due to being mentioned in |
| * unresolved drafts. |
| * If the draft is discarded or edited to remove the mention then we want to |
| * remove the user from being added to CC. |
| * Instead of figuring out when we should remove the mentioned user ie when |
| * they get removed from the last comment, we recompute this property when |
| * any of the draft comments change. |
| * If we add the user to the existing ccs object then we cannot differentiate |
| * if the user was added manually to CC or added due to being mentioned hence |
| * we cannot reset the mentioned ccs when drafts change. |
| */ |
| @state() |
| mentionedUsers: AccountInput[] = []; |
| |
| @state() |
| mentionedUsersInUnresolvedDrafts: AccountInfo[] = []; |
| |
| @state() |
| attentionCcsCount = 0; |
| |
| @state() |
| ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null; |
| |
| @state() |
| messagePlaceholder?: string; |
| |
| @state() |
| uploader?: AccountInfo; |
| |
| @state() |
| pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null; |
| |
| @state() |
| includeComments = true; |
| |
| @state() reviewers: AccountInput[] = []; |
| |
| @state() |
| reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null; |
| |
| @state() |
| savingComments = false; |
| |
| @state() |
| reviewersMutated = false; |
| |
| /** |
| * Signifies that the user has changed their vote on a label or (if they have |
| * not yet voted on a label) if a selected vote is different from the default |
| * vote. |
| */ |
| @state() |
| labelsChanged = false; |
| |
| @state() |
| readonly saveTooltip: string = ButtonTooltips.SAVE; |
| |
| @state() |
| pluginMessage = ''; |
| |
| @state() |
| commentEditing = false; |
| |
| @state() |
| attentionExpanded = false; |
| |
| @state() |
| currentAttentionSet: Set<UserId> = new Set(); |
| |
| @state() |
| newAttentionSet: Set<UserId> = new Set(); |
| |
| @state() |
| manuallyAddedAttentionSet: Set<UserId> = new Set(); |
| |
| @state() |
| manuallyDeletedAttentionSet: Set<UserId> = new Set(); |
| |
| @state() |
| patchsetLevelDraftIsResolved = true; |
| |
| @state() |
| patchsetLevelComment?: DraftInfo; |
| |
| @state() |
| isOwner = false; |
| |
| private readonly restApiService: RestApiService = |
| getAppContext().restApiService; |
| |
| private readonly getPluginLoader = resolve(this, pluginLoaderToken); |
| |
| private readonly getConfigModel = resolve(this, configModelToken); |
| |
| private readonly getAccountsModel = resolve(this, accountsModelToken); |
| |
| private readonly getUserModel = resolve(this, userModelToken); |
| |
| private readonly getNavigation = resolve(this, navigationToken); |
| |
| storeTask?: DelayedTask; |
| |
| private isLoggedIn = false; |
| |
| private readonly shortcuts = new ShortcutController(this); |
| |
| static override get styles() { |
| return [ |
| formStyles, |
| sharedStyles, |
| modalStyles, |
| css` |
| :host { |
| background-color: var(--dialog-background-color); |
| display: block; |
| max-height: 90vh; |
| --label-score-padding-left: var(--spacing-xl); |
| } |
| :host([disabled]) { |
| pointer-events: none; |
| } |
| :host([disabled]) .container { |
| opacity: 0.5; |
| } |
| section { |
| border-top: 1px solid var(--border-color); |
| flex-shrink: 0; |
| padding: var(--spacing-m) var(--spacing-xl); |
| width: 100%; |
| } |
| section.labelsContainer { |
| /* We want the :hover highlight to extend to the border of the dialog. */ |
| padding: var(--spacing-m) 0; |
| } |
| .stickyBottom { |
| background-color: var(--dialog-background-color); |
| box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15); |
| margin-top: var(--spacing-s); |
| bottom: 0; |
| position: sticky; |
| /* @see Issue 8602 */ |
| z-index: 1; |
| } |
| .stickyBottom.newReplyDialog { |
| margin-top: unset; |
| } |
| .actions { |
| display: flex; |
| justify-content: space-between; |
| } |
| .actions .right gr-button { |
| margin-left: var(--spacing-l); |
| } |
| .peopleContainer, |
| .labelsContainer { |
| flex-shrink: 0; |
| } |
| .peopleContainer { |
| border-top: none; |
| display: table; |
| } |
| .peopleList { |
| display: flex; |
| } |
| .peopleListLabel { |
| color: var(--deemphasized-text-color); |
| margin-top: var(--spacing-xs); |
| min-width: 6em; |
| padding-right: var(--spacing-m); |
| } |
| gr-account-list { |
| display: flex; |
| flex-wrap: wrap; |
| flex: 1; |
| } |
| #reviewerConfirmationModal { |
| padding: var(--spacing-l); |
| text-align: center; |
| } |
| .reviewerConfirmationButtons { |
| margin-top: var(--spacing-l); |
| } |
| .groupName { |
| font-weight: var(--font-weight-bold); |
| } |
| .groupSize { |
| font-style: italic; |
| } |
| .textareaContainer { |
| min-height: 12em; |
| position: relative; |
| } |
| .newReplyDialog.textareaContainer { |
| min-height: unset; |
| } |
| textareaContainer, |
| gr-endpoint-decorator[name='reply-text'] { |
| display: flex; |
| width: 100%; |
| } |
| gr-endpoint-decorator[name='reply-text'] { |
| flex-direction: column; |
| } |
| #checkingStatusLabel, |
| #notLatestLabel { |
| margin-left: var(--spacing-l); |
| } |
| #checkingStatusLabel { |
| color: var(--deemphasized-text-color); |
| font-style: italic; |
| } |
| #notLatestLabel, |
| #savingLabel { |
| color: var(--error-text-color); |
| } |
| #savingLabel { |
| display: none; |
| } |
| #savingLabel.saving { |
| display: inline; |
| } |
| #pluginMessage { |
| color: var(--deemphasized-text-color); |
| margin-left: var(--spacing-l); |
| margin-bottom: var(--spacing-m); |
| } |
| #pluginMessage:empty { |
| display: none; |
| } |
| .edit-attention-button { |
| vertical-align: top; |
| --gr-button-padding: 0px 4px; |
| } |
| .edit-attention-button gr-icon { |
| color: inherit; |
| } |
| .attentionSummary .edit-attention-button gr-icon { |
| /* The line-height:26px hack (see below) requires us to do this. |
| Normally the gr-icon would account for a proper positioning |
| within the standard line-height:20px context. */ |
| top: 5px; |
| } |
| .attention a, |
| .attention-detail a { |
| text-decoration: none; |
| } |
| .attentionSummary { |
| display: flex; |
| justify-content: space-between; |
| } |
| .attentionSummary { |
| /* The account label for selection is misbehaving currently: It consumes |
| 26px height instead of 20px, which is the default line-height and thus |
| the max that can be nicely fit into an inline layout flow. We |
| acknowledge that using a fixed 26px value here is a hack and not a |
| great solution. */ |
| line-height: 26px; |
| } |
| .attentionSummary gr-account-label, |
| .attention-detail gr-account-label { |
| --account-max-length: 120px; |
| display: inline-block; |
| padding: var(--spacing-xs) var(--spacing-m); |
| user-select: none; |
| --label-border-radius: 8px; |
| } |
| .attentionSummary gr-account-label { |
| margin: 0 var(--spacing-xs); |
| line-height: var(--line-height-normal); |
| vertical-align: top; |
| } |
| .attention-detail .peopleListValues { |
| line-height: calc(var(--line-height-normal) + 10px); |
| } |
| .attention-detail gr-account-label { |
| line-height: var(--line-height-normal); |
| } |
| .attentionSummary gr-account-label:focus, |
| .attention-detail gr-account-label:focus { |
| outline: none; |
| } |
| .attentionSummary gr-account-label:hover, |
| .attention-detail gr-account-label:hover { |
| box-shadow: var(--elevation-level-1); |
| cursor: pointer; |
| } |
| .attention-detail .attentionDetailsTitle { |
| display: flex; |
| justify-content: space-between; |
| } |
| .attention-detail .selectUsers { |
| color: var(--deemphasized-text-color); |
| margin-bottom: var(--spacing-m); |
| } |
| .attentionTip { |
| padding: var(--spacing-m); |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| margin-top: var(--spacing-m); |
| background-color: var(--line-item-highlight-color); |
| } |
| .attentionTip div gr-icon { |
| margin-right: var(--spacing-s); |
| } |
| .patchsetLevelContainer { |
| width: calc(min(80ch, 100%)); |
| border-radius: var(--border-radius); |
| box-shadow: var(--elevation-level-2); |
| } |
| .patchsetLevelContainer.resolved { |
| background-color: var(--comment-background-color); |
| } |
| .patchsetLevelContainer.unresolved { |
| background-color: var(--unresolved-comment-background-color); |
| } |
| .privateVisiblityInfo { |
| display: flex; |
| justify-content: center; |
| background-color: var(--info-background); |
| padding: var(--spacing-s) 0; |
| } |
| .privateVisiblityInfo gr-icon { |
| margin-right: var(--spacing-m); |
| color: var(--info-foreground); |
| } |
| `, |
| ]; |
| } |
| |
| constructor() { |
| super(); |
| this.filterReviewerSuggestion = |
| this.filterReviewerSuggestionGenerator(false); |
| this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true); |
| |
| this.shortcuts.addLocal({key: Key.ESC}, () => this.cancel()); |
| this.shortcuts.addLocal( |
| {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, |
| () => this.submit() |
| ); |
| this.shortcuts.addLocal( |
| {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, |
| () => this.submit() |
| ); |
| |
| subscribe( |
| this, |
| () => this.getUserModel().loggedIn$, |
| isLoggedIn => (this.isLoggedIn = isLoggedIn) |
| ); |
| subscribe( |
| this, |
| () => this.getUserModel().account$, |
| account => { |
| this.account = account; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().serverConfig$, |
| config => { |
| this.serverConfig = config; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getConfigModel().docsBaseUrl$, |
| docsBaseUrl => (this.docsBaseUrl = docsBaseUrl) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().change$, |
| x => (this.change = x) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().latestPatchNum$, |
| x => (this.latestPatchNum = x) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().isOwner$, |
| x => (this.isOwner = x) |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().mentionedUsersInDrafts$, |
| x => { |
| this.mentionedUsers = x; |
| this.reviewersMutated = |
| this.reviewersMutated || this.mentionedUsers.length > 0; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$, |
| x => { |
| this.mentionedUsersInUnresolvedDrafts = x.filter( |
| v => !this.isAlreadyReviewerOrCC(v) |
| ); |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().patchsetLevelDrafts$, |
| x => (this.patchsetLevelComment = x[0]) |
| ); |
| subscribe( |
| this, |
| () => this.getCommentsModel().draftThreadsSaved$, |
| threads => |
| (this.draftCommentThreads = threads.filter( |
| t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t)) |
| )) |
| ); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| ironAnnouncerRequestAvailability(); |
| |
| this.getPluginLoader().jsApiService.addElement( |
| TargetElement.REPLY_DIALOG, |
| this |
| ); |
| |
| this.addEventListener( |
| 'comment-editing-changed', |
| (e: CustomEvent<CommentEditingChangedDetail>) => { |
| // Patchset level comment is always in editing mode which means it would |
| // set commentEditing = true and the send button would be permanently |
| // disabled. |
| if (e.detail.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) return; |
| const commentList = queryAndAssert<GrThreadList>(this, '#commentList'); |
| // It can be one or more comments were in editing mode. Wwitching one |
| // thread in editing, we need to check if there are still other threads |
| // in editing. |
| this.commentEditing = Array.from(commentList.threadElements ?? []).some( |
| thread => thread.editing |
| ); |
| } |
| ); |
| |
| // Plugins on reply-reviewers endpoint can take advantage of these |
| // events to add / remove reviewers |
| |
| this.addEventListener('add-reviewer', (e: AddReviewerEvent) => { |
| const reviewer = e.detail.reviewer; |
| // Only support account type, see more from: |
| // elements/shared/gr-account-list/gr-account-list.js#addAccountItem |
| this.reviewersList?.addAccountItem({ |
| account: reviewer, |
| count: 1, |
| }); |
| }); |
| |
| this.addEventListener('remove-reviewer', (e: RemoveReviewerEvent) => { |
| const reviewer = e.detail.reviewer; |
| this.reviewersList?.removeAccount(reviewer); |
| }); |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| if (changedProperties.has('ccPendingConfirmation')) { |
| this.pendingConfirmationUpdated(this.ccPendingConfirmation); |
| } |
| if (changedProperties.has('reviewerPendingConfirmation')) { |
| this.pendingConfirmationUpdated(this.reviewerPendingConfirmation); |
| } |
| if (changedProperties.has('change')) { |
| this.computeUploader(); |
| this.rebuildReviewerArrays(); |
| } |
| if (changedProperties.has('canBeStarted')) { |
| this.computeMessagePlaceholder(); |
| } |
| if (changedProperties.has('attentionExpanded')) { |
| this.onAttentionExpandedChange(); |
| } |
| if ( |
| changedProperties.has('account') || |
| changedProperties.has('reviewers') || |
| changedProperties.has('ccs') || |
| changedProperties.has('change') || |
| changedProperties.has('draftCommentThreads') || |
| changedProperties.has('mentionedUsersInUnresolvedDrafts') || |
| changedProperties.has('includeComments') || |
| changedProperties.has('labelsChanged') || |
| changedProperties.has('patchsetLevelDraftMessage') || |
| changedProperties.has('mentionedCCs') |
| ) { |
| this.computeNewAttention(); |
| } |
| } |
| |
| override disconnectedCallback() { |
| this.storeTask?.flush(); |
| super.disconnectedCallback(); |
| } |
| |
| override render() { |
| if (!this.change) return; |
| return html` |
| <div tabindex="-1"> |
| <section class="peopleContainer"> |
| <gr-endpoint-decorator name="reply-reviewers"> |
| <gr-endpoint-param |
| name="change" |
| .value=${this.change} |
| ></gr-endpoint-param> |
| <gr-endpoint-param name="reviewers" .value=${[...this.reviewers]}> |
| </gr-endpoint-param> |
| ${this.renderReviewerList()} |
| <gr-endpoint-slot name="below"></gr-endpoint-slot> |
| </gr-endpoint-decorator> |
| ${this.renderCCList()} ${this.renderReviewConfirmation()} |
| ${this.renderPrivateVisiblityInfo()} |
| </section> |
| <section class="labelsContainer">${this.renderLabels()}</section> |
| <section class="newReplyDialog textareaContainer"> |
| ${this.renderReplyText()} |
| </section> |
| ${this.renderDraftsSection()} |
| <div class="stickyBottom newReplyDialog"> |
| <gr-endpoint-decorator name="reply-bottom"> |
| <gr-endpoint-param |
| name="change" |
| .value=${this.change} |
| ></gr-endpoint-param> |
| ${when( |
| this.attentionExpanded, |
| () => this.renderAttentionDetailsSection(), |
| () => this.renderAttentionSummarySection() |
| )} |
| <gr-endpoint-slot name="above-actions"></gr-endpoint-slot> |
| ${this.renderActionsSection()} |
| </gr-endpoint-decorator> |
| </div> |
| </div> |
| `; |
| } |
| |
| private renderReviewerList() { |
| return html` |
| <div class="peopleList"> |
| <div class="peopleListLabel">Reviewers</div> |
| <gr-account-list |
| id="reviewers" |
| .accounts=${[...this.reviewers]} |
| .change=${this.change} |
| .reviewerState=${ReviewerState.REVIEWER} |
| @account-added=${this.handleAccountAdded} |
| @accounts-changed=${this.handleReviewersChanged} |
| .removableValues=${this.change?.removable_reviewers} |
| .filter=${this.filterReviewerSuggestion} |
| .pendingConfirmation=${this.reviewerPendingConfirmation} |
| @pending-confirmation-changed=${this |
| .handleReviewersConfirmationChanged} |
| .placeholder=${'Add reviewer...'} |
| @account-text-changed=${this.handleAccountTextEntry} |
| .suggestionsProvider=${this.getReviewerSuggestionsProvider( |
| this.change |
| )} |
| > |
| </gr-account-list> |
| <gr-endpoint-slot name="right"></gr-endpoint-slot> |
| </div> |
| `; |
| } |
| |
| private renderCCList() { |
| return html` |
| <div class="peopleList"> |
| <div class="peopleListLabel">CC</div> |
| <gr-account-list |
| id="ccs" |
| .accounts=${[...this.ccs]} |
| .change=${this.change} |
| .reviewerState=${ReviewerState.CC} |
| @account-added=${this.handleAccountAdded} |
| @accounts-changed=${this.handleCcsChanged} |
| .filter=${this.filterCCSuggestion} |
| .pendingConfirmation=${this.ccPendingConfirmation} |
| @pending-confirmation-changed=${this.handleCcsConfirmationChanged} |
| allow-any-input |
| .placeholder=${'Add CC...'} |
| @account-text-changed=${this.handleAccountTextEntry} |
| .suggestionsProvider=${this.getCcSuggestionsProvider(this.change)} |
| > |
| </gr-account-list> |
| </div> |
| `; |
| } |
| |
| private renderReviewConfirmation() { |
| return html` |
| <dialog |
| tabindex="-1" |
| id="reviewerConfirmationModal" |
| @close=${this.cancelPendingReviewer} |
| > |
| <div class="reviewerConfirmation"> |
| Group |
| <span class="groupName"> |
| ${this.pendingConfirmationDetails?.group.name} |
| </span> |
| has |
| <span class="groupSize"> |
| ${this.pendingConfirmationDetails?.count} |
| </span> |
| members. |
| <br /> |
| Are you sure you want to add them all? |
| </div> |
| <div class="reviewerConfirmationButtons"> |
| <gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button> |
| <gr-button @click=${this.cancelPendingReviewer}>No</gr-button> |
| </div> |
| </dialog> |
| `; |
| } |
| |
| private renderPrivateVisiblityInfo() { |
| const addedAccounts = [ |
| ...(this.reviewersList?.additions() ?? []), |
| ...(this.ccsList?.additions() ?? []), |
| ]; |
| if (!this.change?.is_private || !addedAccounts.length) return nothing; |
| return html` |
| <div class="privateVisiblityInfo"> |
| <gr-icon icon="info"></gr-icon> |
| <div> |
| Adding a reviewer/CC will make this private change visible to them |
| </div> |
| </div> |
| `; |
| } |
| |
| private renderLabels() { |
| if (!this.change || !this.account || !this.permittedLabels) return; |
| return html` |
| <gr-endpoint-decorator name="reply-label-scores"> |
| <gr-label-scores |
| id="labelScores" |
| .account=${this.account} |
| .change=${this.change} |
| @labels-changed=${this._handleLabelsChanged} |
| .permittedLabels=${this.permittedLabels} |
| ></gr-label-scores> |
| <gr-endpoint-param |
| name="change" |
| .value=${this.change} |
| ></gr-endpoint-param> |
| </gr-endpoint-decorator> |
| <div id="pluginMessage">${this.pluginMessage}</div> |
| `; |
| } |
| |
| private renderPatchsetLevelComment() { |
| if (!this.patchsetLevelComment) return nothing; |
| return html` |
| <gr-comment |
| id="patchsetLevelComment" |
| .comment=${this.patchsetLevelComment} |
| .comments=${[this.patchsetLevelComment]} |
| @comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => { |
| this.patchsetLevelDraftIsResolved = !e.detail.value; |
| }} |
| @comment-text-changed=${(e: ValueChangedEvent<string>) => { |
| this.patchsetLevelDraftMessage = e.detail.value; |
| // See `addReplyTextChangedCallback` in `ChangeReplyPluginApi`. |
| fire(e.currentTarget as HTMLElement, 'value-changed', e.detail); |
| }} |
| .messagePlaceholder=${this.messagePlaceholder} |
| hide-header |
| permanent-editing-mode |
| ></gr-comment> |
| `; |
| } |
| |
| private renderReplyText() { |
| if (!this.change) return; |
| return html` |
| <div |
| class=${classMap({ |
| patchsetLevelContainer: true, |
| [this.getUnresolvedPatchsetLevelClass( |
| this.patchsetLevelDraftIsResolved |
| )]: true, |
| })} |
| > |
| <gr-endpoint-decorator name="reply-text"> |
| ${this.renderPatchsetLevelComment()} |
| <gr-endpoint-param name="change" .value=${this.change}> |
| </gr-endpoint-param> |
| </gr-endpoint-decorator> |
| </div> |
| `; |
| } |
| |
| private renderDraftsSection() { |
| const threads = this.draftCommentThreads; |
| if (!threads || threads.length === 0) return; |
| return html` |
| <section class="draftsContainer"> |
| <div class="includeComments"> |
| <input |
| type="checkbox" |
| id="includeComments" |
| @change=${this.handleIncludeCommentsChanged} |
| ?checked=${this.includeComments} |
| /> |
| <label for="includeComments" |
| >Publish ${this.computeDraftsTitle(threads)}</label |
| > |
| </div> |
| ${when( |
| this.includeComments, |
| () => html` |
| <gr-thread-list id="commentList" .threads=${threads} hide-dropdown> |
| </gr-thread-list> |
| ` |
| )} |
| <span |
| id="savingLabel" |
| class=${this.computeSavingLabelClass(this.savingComments)} |
| > |
| Saving comments... |
| </span> |
| </section> |
| `; |
| } |
| |
| private renderAttentionSummarySection() { |
| return html` |
| <section class="attention"> |
| <div class="attentionSummary"> |
| <div> |
| ${when( |
| this.computeShowNoAttentionUpdate(), |
| () => html` <span>${this.computeDoNotUpdateMessage()}</span> ` |
| )} |
| ${when( |
| !this.computeShowNoAttentionUpdate(), |
| () => html` |
| <span>Bring to attention of</span> |
| ${this.computeNewAttentionAccounts().map( |
| account => html` |
| <gr-account-label |
| .account=${account} |
| .forceAttention=${this.computeHasNewAttention(account)} |
| .selected=${this.computeHasNewAttention(account)} |
| .hideHovercard=${true} |
| .selectionChipStyle=${true} |
| @click=${this.handleAttentionClick} |
| ></gr-account-label> |
| ` |
| )} |
| ` |
| )} |
| </div> |
| <div> |
| ${this.renderModifyAttentionSetButton()} |
| <a |
| href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')} |
| target="_blank" |
| rel="noopener noreferrer" |
| > |
| <gr-icon icon="help" title="read documentation"></gr-icon> |
| </a> |
| </div> |
| </div> |
| </section> |
| `; |
| } |
| |
| private renderModifyAttentionSetButton() { |
| return html` <gr-button |
| class="edit-attention-button" |
| @click=${this.toggleAttentionModify} |
| link |
| position-below |
| data-label="Edit" |
| data-action-type="change" |
| data-action-key="edit" |
| role="button" |
| tabindex="0" |
| > |
| <div> |
| <gr-icon |
| icon=${this.attentionExpanded ? 'expand_circle_up' : 'edit'} |
| filled |
| small |
| ></gr-icon> |
| <span>${this.attentionExpanded ? 'Collapse' : 'Modify'}</span> |
| </div> |
| </gr-button>`; |
| } |
| |
| private renderAttentionDetailsSection() { |
| return html` |
| <section class="attention-detail"> |
| <div class="attentionDetailsTitle"> |
| <div> |
| <span>Modify attention to</span> |
| </div> |
| |
| <div></div> |
| <div> |
| ${this.renderModifyAttentionSetButton()} |
| <a |
| href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')} |
| target="_blank" |
| rel="noopener noreferrer" |
| > |
| <gr-icon icon="help" title="read documentation"></gr-icon> |
| </a> |
| </div> |
| </div> |
| <div class="selectUsers"> |
| <span |
| >Select chips to set who will be in the attention set after sending |
| this reply</span |
| > |
| </div> |
| <div class="peopleList"> |
| <div class="peopleListLabel">Owner</div> |
| <div class="peopleListValues"> |
| <gr-account-label |
| .account=${this.change?.owner} |
| ?forceAttention=${this.computeHasNewAttention(this.change?.owner)} |
| .selected=${this.computeHasNewAttention(this.change?.owner)} |
| .hideHovercard=${true} |
| .selectionChipStyle=${true} |
| @click=${this.handleAttentionClick} |
| > |
| </gr-account-label> |
| </div> |
| </div> |
| ${when( |
| this.uploader, |
| () => html` |
| <div class="peopleList"> |
| <div class="peopleListLabel">Uploader</div> |
| <div class="peopleListValues"> |
| <gr-account-label |
| .account=${this.uploader} |
| ?forceAttention=${this.computeHasNewAttention(this.uploader)} |
| .selected=${this.computeHasNewAttention(this.uploader)} |
| .hideHovercard=${true} |
| .selectionChipStyle=${true} |
| @click=${this.handleAttentionClick} |
| > |
| </gr-account-label> |
| </div> |
| </div> |
| ` |
| )} |
| <div class="peopleList"> |
| <div class="peopleListLabel">Reviewers</div> |
| <div class="peopleListValues"> |
| ${removeServiceUsers(this.reviewers).map( |
| account => html` |
| <gr-account-label |
| .account=${account} |
| ?forceAttention=${this.computeHasNewAttention(account)} |
| .selected=${this.computeHasNewAttention(account)} |
| .hideHovercard=${true} |
| .selectionChipStyle=${true} |
| @click=${this.handleAttentionClick} |
| > |
| </gr-account-label> |
| ` |
| )} |
| </div> |
| </div> |
| |
| ${when( |
| this.attentionCcsCount, |
| () => html` |
| <div class="peopleList"> |
| <div class="peopleListLabel">CC</div> |
| <div class="peopleListValues"> |
| ${removeServiceUsers(this.ccs).map( |
| account => html` |
| <gr-account-label |
| .account=${account} |
| ?forceAttention=${this.computeHasNewAttention(account)} |
| .selected=${this.computeHasNewAttention(account)} |
| .hideHovercard=${true} |
| .selectionChipStyle=${true} |
| @click=${this.handleAttentionClick} |
| > |
| </gr-account-label> |
| ` |
| )} |
| </div> |
| </div> |
| ` |
| )} |
| ${when( |
| this.computeShowAttentionTip(3), |
| () => html` |
| <div class="attentionTip"> |
| <gr-icon icon="lightbulb"></gr-icon> |
| Please be mindful of requiring attention from too many users. |
| </div> |
| ` |
| )} |
| </section> |
| `; |
| } |
| |
| private renderActionsSection() { |
| return html` |
| <section class="actions"> |
| <div class="left"> |
| ${when( |
| this.knownLatestState === LatestPatchState.CHECKING, |
| () => html` |
| <span id="checkingStatusLabel"> |
| Checking whether patch ${this.latestPatchNum} is latest... |
| </span> |
| ` |
| )} |
| ${when( |
| this.knownLatestState === LatestPatchState.NOT_LATEST, |
| () => html` |
| <span id="notLatestLabel"> |
| ${this.computePatchSetWarning()} |
| <gr-button link @click=${this._reload}>Reload</gr-button> |
| </span> |
| ` |
| )} |
| </div> |
| <div class="right"> |
| <gr-button |
| link |
| id="cancelButton" |
| class="action cancel" |
| @click=${this.cancelTapHandler} |
| >Cancel</gr-button |
| > |
| ${when( |
| this.canBeStarted, |
| () => html` |
| <!-- Use 'Send' here as the change may only about reviewers / ccs |
| and when this button is visible, the next button will always |
| be 'Start review' --> |
| <gr-tooltip-content has-tooltip title=${this.saveTooltip}> |
| <gr-button |
| link |
| ?disabled=${this.knownLatestState === |
| LatestPatchState.NOT_LATEST} |
| class="action save" |
| @click=${this.saveClickHandler} |
| >Send As WIP</gr-button |
| > |
| </gr-tooltip-content> |
| ` |
| )} |
| <gr-tooltip-content |
| has-tooltip |
| title=${this.computeSendButtonTooltip( |
| this.canBeStarted, |
| this.commentEditing |
| )} |
| > |
| <gr-button |
| id="sendButton" |
| primary |
| ?disabled=${this.isSendDisabled()} |
| class="action send" |
| @click=${this.sendClickHandler} |
| >${this.canBeStarted |
| ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW |
| : ButtonLabels.SEND} |
| </gr-button> |
| </gr-tooltip-content> |
| </div> |
| </section> |
| `; |
| } |
| |
| /** |
| * Note that this method is not actually *opening* the dialog. Opening and |
| * showing the dialog is dealt with by the overlay. This method is used by the |
| * change view for initializing the dialog after opening the overlay. Maybe it |
| * should be called `onOpened()` or `initialize()`? |
| */ |
| open(focusTarget?: FocusTarget) { |
| assertIsDefined(this.change, 'change'); |
| this.knownLatestState = LatestPatchState.CHECKING; |
| this.getChangeModel() |
| .fetchChangeUpdates(this.change) |
| .then(result => { |
| this.knownLatestState = result.isLatest |
| ? LatestPatchState.LATEST |
| : LatestPatchState.NOT_LATEST; |
| }); |
| |
| this.focusOn(focusTarget); |
| if (this.restApiService.hasPendingDiffDrafts()) { |
| this.savingComments = true; |
| this.restApiService.awaitPendingDiffDrafts().then(() => { |
| fire(this, 'comment-refresh', {}); |
| this.savingComments = false; |
| }); |
| } |
| } |
| |
| hasDrafts() { |
| return ( |
| this.patchsetLevelDraftMessage.length > 0 || |
| this.draftCommentThreads.length > 0 |
| ); |
| } |
| |
| override focus() { |
| this.focusOn(FocusTarget.ANY); |
| } |
| |
| private handleIncludeCommentsChanged(e: Event) { |
| if (!(e.target instanceof HTMLInputElement)) return; |
| this.includeComments = e.target.checked; |
| } |
| |
| setLabelValue(label: string, value: string): void { |
| const selectorEl = |
| this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>( |
| `gr-label-score-row[name="${label}"]` |
| ); |
| selectorEl?.setSelectedValue(value); |
| } |
| |
| getLabelValue(label: string) { |
| const selectorEl = |
| this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>( |
| `gr-label-score-row[name="${label}"]` |
| ); |
| return selectorEl?.selectedValue; |
| } |
| |
| // TODO: Combine logic into handleReviewersChanged & handleCCsChanged and |
| // remove account-added event from GrAccountList. |
| handleAccountAdded(e: CustomEvent<AccountInputDetail>) { |
| const account = e.detail.account; |
| const key = getUserId(account); |
| const reviewerType = |
| (e.target as GrAccountList).getAttribute('id') === 'ccs' |
| ? ReviewerType.CC |
| : ReviewerType.REVIEWER; |
| const isReviewer = ReviewerType.REVIEWER === reviewerType; |
| const reviewerList = isReviewer ? this.ccsList : this.reviewersList; |
| // Remove any accounts that already exist as a CC for reviewer |
| // or vice versa. |
| if (reviewerList?.removeAccount(account)) { |
| const moveFrom = isReviewer ? 'CC' : 'reviewer'; |
| const moveTo = isReviewer ? 'reviewer' : 'CC'; |
| const id = account.name || key; |
| const message = `${id} moved from ${moveFrom} to ${moveTo}.`; |
| fireAlert(this, message); |
| } |
| } |
| |
| getUnresolvedPatchsetLevelClass(patchsetLevelDraftIsResolved: boolean) { |
| return patchsetLevelDraftIsResolved ? 'resolved' : 'unresolved'; |
| } |
| |
| computeReviewers() { |
| const reviewers: ReviewerInput[] = []; |
| const reviewerAdditions = this.reviewersList?.additions() ?? []; |
| reviewers.push( |
| ...reviewerAdditions.map(v => toReviewInput(v, ReviewerState.REVIEWER)) |
| ); |
| |
| const ccAdditions = this.ccsList?.additions() ?? []; |
| reviewers.push(...ccAdditions.map(v => toReviewInput(v, ReviewerState.CC))); |
| |
| // ignore removal from reviewer request if being added as CC |
| let removals = difference( |
| this.reviewersList?.removals() ?? [], |
| ccAdditions, |
| (a, b) => getUserId(a) === getUserId(b) |
| ).map(v => toReviewInput(v, ReviewerState.REMOVED)); |
| reviewers.push(...removals); |
| |
| // ignore removal from CC request if being added as reviewer |
| removals = difference( |
| this.ccsList?.removals() ?? [], |
| reviewerAdditions, |
| (a, b) => getUserId(a) === getUserId(b) |
| ).map(v => toReviewInput(v, ReviewerState.REMOVED)); |
| reviewers.push(...removals); |
| |
| // The owner is returned as a reviewer in the ChangeInfo object in some |
| // cases, and trying to remove the owner as a reviewer returns in a |
| // 500 server error. |
| return reviewers.filter( |
| reviewerInput => |
| !( |
| this.change?.owner._account_id === reviewerInput.reviewer && |
| reviewerInput.state === ReviewerState.REMOVED |
| ) |
| ); |
| } |
| |
| // visible for testing |
| async send(includeComments: boolean, startReview: boolean) { |
| // ChangeModel will be updated once the reply returns at which point the |
| // timer will be ended. |
| this.reporting.time(Timing.SEND_REPLY); |
| const labels = this.getLabelScores().getLabelValues(); |
| if (labels[StandardLabels.CODE_REVIEW] === 2) { |
| this.reporting.reportInteraction(Interaction.CODE_REVIEW_APPROVAL); |
| } |
| |
| const reviewInput: ReviewInput = { |
| drafts: includeComments |
| ? DraftsAction.PUBLISH_ALL_REVISIONS |
| : DraftsAction.KEEP, |
| labels, |
| }; |
| |
| if (startReview) { |
| reviewInput.ready = true; |
| } else if (this.change?.work_in_progress) { |
| const addedAccounts = [ |
| ...(this.reviewersList?.additions() ?? []), |
| ...(this.ccsList?.additions() ?? []), |
| ]; |
| if (addedAccounts.length > 0) { |
| fireAlert(this, 'Reviewers are not notified for WIP changes'); |
| } |
| } |
| |
| this.disabled = true; |
| |
| const reason = getReplyByReason(this.account, this.serverConfig); |
| |
| reviewInput.ignore_automatic_attention_set_rules = true; |
| reviewInput.add_to_attention_set = []; |
| const allAccounts = this.allAccounts(); |
| |
| const newAttentionSetAdditions: AccountInfo[] = Array.from( |
| this.newAttentionSet |
| ) |
| .filter(user => !this.currentAttentionSet.has(user)) |
| .map(user => allAccounts.find(a => getUserId(a) === user)) |
| .filter(isDefined); |
| |
| const newAttentionSetUsers = ( |
| await Promise.all( |
| newAttentionSetAdditions.map(a => |
| this.getAccountsModel().fillDetails(a) |
| ) |
| ) |
| ).filter(isDefined); |
| |
| for (const user of newAttentionSetUsers) { |
| const reason = |
| getMentionedReason( |
| this.draftCommentThreads, |
| this.account, |
| user, |
| this.serverConfig |
| ) ?? ''; |
| reviewInput.add_to_attention_set.push({user: getUserId(user), reason}); |
| } |
| reviewInput.remove_from_attention_set = []; |
| for (const user of this.currentAttentionSet) { |
| if (!this.newAttentionSet.has(user)) { |
| reviewInput.remove_from_attention_set.push({user, reason}); |
| } |
| } |
| this.reportAttentionSetChanges( |
| this.attentionExpanded, |
| reviewInput.add_to_attention_set, |
| reviewInput.remove_from_attention_set |
| ); |
| |
| if (this.patchsetLevelGrComment) { |
| this.patchsetLevelGrComment.disableAutoSaving = true; |
| await this.restApiService.awaitPendingDiffDrafts(); |
| const comment = |
| await this.patchsetLevelGrComment.convertToCommentInputAndOrDiscard(); |
| if (comment && comment.path && comment.message) { |
| reviewInput.comments ??= {}; |
| reviewInput.comments[comment.path] ??= []; |
| reviewInput.comments[comment.path].push(comment); |
| } |
| } |
| |
| assertIsDefined(this.change, 'change'); |
| reviewInput.reviewers = this.computeReviewers(); |
| this.reportStartReview(reviewInput); |
| |
| const errFn = (r?: Response | null) => this.handle400Error(r); |
| this.getNavigation().blockNavigation('sending review'); |
| return this.saveReview(reviewInput, errFn) |
| .then(result => { |
| // change-info is not set only if request resulted in error. |
| if (!result?.change_info) { |
| return; |
| } |
| |
| // saveReview response don't contain revision information, if the |
| // newer patchset was uploaded in the meantime, we should reload. |
| const reloadRequired = |
| result.change_info.current_revision_number !== |
| this.change?.current_revision_number; |
| // Update the state right away to update comments, even if the full |
| // reload is scheduled right after. |
| const updatedChange = { |
| ...result.change_info, |
| revisions: this.change?.revisions, |
| current_revision: this.change?.current_revision, |
| current_revision_number: this.change?.current_revision_number, |
| }; |
| this.getChangeModel().updateStateChange( |
| GrReviewerUpdatesParser.parse(updatedChange as ChangeViewChangeInfo) |
| ); |
| if (reloadRequired) { |
| fireReload(this); |
| } |
| |
| this.patchsetLevelDraftMessage = ''; |
| this.includeComments = true; |
| fireNoBubble(this, 'send', {}); |
| fireIronAnnounce(this, 'Reply sent'); |
| this.getPluginLoader().jsApiService.handleReplySent(); |
| }) |
| .finally(() => { |
| this.getNavigation().releaseNavigation('sending review'); |
| this.disabled = false; |
| if (this.patchsetLevelGrComment) { |
| this.patchsetLevelGrComment.disableAutoSaving = false; |
| } |
| // The request finished and reloads if necessary are asynchronously |
| // scheduled. |
| this.reporting.timeEnd(Timing.SEND_REPLY); |
| }); |
| } |
| |
| private reportStartReview(reviewInput: ReviewInput) { |
| const changeHasReviewers = |
| (this.change?.reviewers.REVIEWER ?? []).length > 0; |
| const newReviewersAdded = |
| (this.reviewersList?.additions() ?? []).length > 0; |
| |
| // A review starts if either a WIP change is set to active with reviewers ... |
| const setActiveWithReviewers = |
| this.change?.work_in_progress && |
| reviewInput.ready && |
| // Setting a change active and *removing* all reviewers at the same time |
| // is an obscure corner case that we don't care about. :-) |
| (changeHasReviewers || newReviewersAdded); |
| // ... or if reviewers are added to an already active change that has no reviewers yet. |
| const isActiveAddReviewers = |
| !this.change?.work_in_progress && |
| !reviewInput.work_in_progress && |
| !changeHasReviewers && |
| newReviewersAdded; |
| |
| if (setActiveWithReviewers || isActiveAddReviewers) { |
| this.reporting.reportInteraction(Interaction.START_REVIEW); |
| } |
| } |
| |
| focusOn(section?: FocusTarget) { |
| // Safeguard- always want to focus on something. |
| if (!section || section === FocusTarget.ANY) { |
| section = this.chooseFocusTarget(); |
| } |
| whenVisible(this, () => { |
| if (section === FocusTarget.REVIEWERS) { |
| const reviewerEntry = this.reviewersList?.focusStart; |
| reviewerEntry?.focus(); |
| } else if (section === FocusTarget.CCS) { |
| const ccEntry = this.ccsList?.focusStart; |
| ccEntry?.focus(); |
| } else { |
| this.patchsetLevelGrComment?.focus(); |
| } |
| }); |
| } |
| |
| chooseFocusTarget() { |
| if (!this.isOwner) return FocusTarget.BODY; |
| if (hasHumanReviewer(this.change)) return FocusTarget.BODY; |
| return FocusTarget.REVIEWERS; |
| } |
| |
| handle400Error(r?: Response | null) { |
| if (!r) throw new Error('Response is empty.'); |
| let response: Response = r; |
| // A call to saveReview could fail with a server error if erroneous |
| // reviewers were requested. This is signalled with a 400 Bad Request |
| // status. The default gr-rest-api error handling would result in a large |
| // JSON response body being displayed to the user in the gr-error-manager |
| // toast. |
| // |
| // We can modify the error handling behavior by passing this function |
| // through to restAPI as a custom error handling function. Since we're |
| // short-circuiting restAPI we can do our own response parsing and fire |
| // the server-error ourselves. |
| // |
| this.disabled = false; |
| |
| // Using response.clone() here, because readJSONResponsePayload() and |
| // potentially the generic error handler will want to call text() on the |
| // response object, which can only be done once per object. |
| const jsonPromise = readJSONResponsePayload(response.clone()); |
| return jsonPromise |
| .then((payload: ResponsePayload) => { |
| const result = payload.parsed as ReviewResult; |
| // Only perform custom error handling for 400s and a parsable |
| // ReviewResult response. |
| if (response.status === 400 && result.reviewers) { |
| const errors: string[] = []; |
| const addReviewers = Object.values(result.reviewers); |
| addReviewers.forEach(r => errors.push(r.error ?? 'no explanation')); |
| response = { |
| ...response, |
| ok: false, |
| text: () => Promise.resolve(errors.join(', ')), |
| }; |
| } |
| }) |
| .finally(() => { |
| fireServerError(response); |
| }); |
| } |
| |
| computeDraftsTitle(draftCommentThreads?: CommentThread[]) { |
| const total = draftCommentThreads ? draftCommentThreads.length : 0; |
| return pluralize(total, 'Draft'); |
| } |
| |
| computeMessagePlaceholder() { |
| this.messagePlaceholder = this.canBeStarted |
| ? 'Add a note for your reviewers...' |
| : 'Say something nice...'; |
| } |
| |
| rebuildReviewerArrays() { |
| if (!this.change?.owner || !this.change?.reviewers) return; |
| const getAccounts = (state: ReviewerState) => |
| Object.values(this.change?.reviewers[state] ?? []).filter( |
| account => account._account_id !== this.change!.owner._account_id |
| ); |
| |
| this.ccs = getAccounts(ReviewerState.CC); |
| this.reviewers = getAccounts(ReviewerState.REVIEWER); |
| } |
| |
| toggleAttentionModify() { |
| this.attentionExpanded = !this.attentionExpanded; |
| } |
| |
| onAttentionExpandedChange() { |
| // If the attention-detail section is expanded without dispatching this |
| // event, then the dialog may expand beyond the screen's bottom border. |
| fire(this, 'iron-resize', {}); |
| } |
| |
| handleAttentionClick(e: Event) { |
| const targetAccount = (e.target as GrAccountChip)?.account; |
| if (!targetAccount) return; |
| const id = getUserId(targetAccount); |
| if (!id || !this.account || !this.change?.owner) return; |
| |
| const self = id === getUserId(this.account) ? '_SELF' : ''; |
| const role = id === getUserId(this.change.owner) ? 'OWNER' : '_REVIEWER'; |
| |
| if (this.newAttentionSet.has(id)) { |
| this.newAttentionSet.delete(id); |
| this.manuallyAddedAttentionSet.delete(id); |
| this.manuallyDeletedAttentionSet.add(id); |
| this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, { |
| action: `REMOVE${self}${role}`, |
| }); |
| } else { |
| this.newAttentionSet.add(id); |
| this.manuallyAddedAttentionSet.add(id); |
| this.manuallyDeletedAttentionSet.delete(id); |
| this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, { |
| action: `ADD${self}${role}`, |
| }); |
| } |
| |
| this.requestUpdate(); |
| } |
| |
| computeHasNewAttention(account?: AccountInfo) { |
| return !!(account && this.newAttentionSet?.has(getUserId(account))); |
| } |
| |
| computeNewAttention() { |
| if ( |
| this.account?._account_id === undefined || |
| this.change === undefined || |
| this.includeComments === undefined |
| ) { |
| return; |
| } |
| // The draft comments are only relevant for the attention set as long as the |
| // user actually plans to publish their drafts. |
| const draftCommentThreads = this.includeComments |
| ? this.draftCommentThreads |
| : []; |
| const hasVote = !!this.labelsChanged; |
| const isUploader = this.uploader?._account_id === this.account._account_id; |
| |
| this.attentionCcsCount = removeServiceUsers(this.ccs).length; |
| this.currentAttentionSet = new Set( |
| Object.keys(this.change.attention_set || {}).map( |
| id => Number(id) as AccountId |
| ) |
| ); |
| const newAttention = new Set(this.currentAttentionSet); |
| |
| for (const user of this.mentionedUsersInUnresolvedDrafts) { |
| newAttention.add(getUserId(user)); |
| } |
| |
| if (this.change.status === ChangeStatus.NEW) { |
| // Add everyone that the user is replying to in a comment thread. |
| this.computeCommentAccountsForAttention( |
| draftCommentThreads, |
| isUploader |
| ).forEach(id => newAttention.add(id)); |
| // Remove the current user. |
| newAttention.delete(this.account._account_id); |
| // Add all new reviewers, but not the current reviewer, if they are also |
| // sending a draft or a label vote. |
| const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) => |
| !( |
| r._account_id === this.account!._account_id && |
| (this.hasDrafts() || hasVote) |
| ); |
| this.reviewers |
| .filter(r => isAccount(r)) |
| .filter( |
| r => |
| isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) || |
| (this.canBeStarted && this.isOwner) |
| ) |
| .filter(notIsReviewerAndHasDraftOrLabel) |
| .forEach(r => newAttention.add((r as AccountInfo)._account_id!)); |
| // Add owner and uploader, if someone else replies. |
| if (this.hasDrafts() || hasVote) { |
| if (this.uploader?._account_id && !isUploader) { |
| newAttention.add(this.uploader._account_id); |
| } |
| if (this.change.owner?._account_id && !this.isOwner) { |
| newAttention.add(this.change.owner._account_id); |
| } |
| } |
| } else { |
| // The only reason for adding someone to the attention set for merged or |
| // abandoned changes is that someone makes a comment thread unresolved. |
| const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved); |
| if (this.change.owner && hasUnresolvedDraft) { |
| // A change owner must have an account_id. |
| newAttention.add(this.change.owner._account_id!); |
| } |
| // Remove the current user. |
| newAttention.delete(this.account._account_id); |
| } |
| // Finally make sure that everyone in the attention set is still active as |
| // owner, reviewer or cc. |
| const allAccountIds = this.allAccounts() |
| .map(a => getUserId(a)) |
| .filter(id => !!id); |
| this.newAttentionSet = new Set([ |
| ...this.manuallyAddedAttentionSet, |
| ...[...newAttention].filter( |
| id => |
| allAccountIds.includes(id) && |
| !this.manuallyDeletedAttentionSet.has(id) |
| ), |
| ]); |
| // Possibly expand if need be, never collapse as this is jarring to the user. |
| // For long account lists (10 or more), avoid automatic expansion. |
| this.attentionExpanded = |
| this.attentionExpanded || |
| (allAccountIds.length < 10 && this.computeShowAttentionTip(1)); |
| } |
| |
| computeShowAttentionTip(minimum: number) { |
| if (!this.currentAttentionSet || !this.newAttentionSet) return false; |
| const addedIds = [...this.newAttentionSet].filter( |
| id => !this.currentAttentionSet.has(id) |
| ); |
| return this.isOwner && addedIds.length >= minimum; |
| } |
| |
| /** |
| * Pick previous commenters for addition to attention set. |
| * |
| * For every thread: |
| * - If owner replied and thread is unresolved: add all commenters. |
| * - If owner replied and thread is resolved: add commenters who need to vote. |
| * - If reviewer replied and thread is resolved: add commenters who need to vote. |
| * - If reviewer replied and thread is unresolved: only add owner |
| * (owner added outside this function). |
| */ |
| computeCommentAccountsForAttention( |
| threads: CommentThread[], |
| isUploader: boolean |
| ) { |
| const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW]; |
| const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id); |
| const accountIds = new Set<AccountId>(); |
| threads.forEach(thread => { |
| const unresolved = isUnresolved(thread); |
| let ignoreVoteCheck = false; |
| if (unresolved) { |
| if (this.isOwner || isUploader) { |
| // Owner replied but didn't resolve, we assume clarification was asked |
| // add everyone on the thread to attention set. |
| ignoreVoteCheck = true; |
| } else { |
| // Reviewer replied owner is still the one to act. No need to add |
| // commenters. |
| return; |
| } |
| } |
| // If thread is resolved, we only bring back the commenters who have not |
| // yet left max Code-Review vote. |
| thread.comments.forEach(comment => { |
| if (comment.author) { |
| // A comment author must have an account_id. |
| const authorId = comment.author._account_id!; |
| const needsToVote = |
| !maxCrVoteAccountIds.includes(authorId) && // Didn't give max-vote |
| this.uploader?._account_id !== authorId && // Not uploader |
| this.change?.owner._account_id !== authorId; // Not owner |
| if (ignoreVoteCheck || needsToVote) accountIds.add(authorId); |
| } |
| }); |
| }); |
| return accountIds; |
| } |
| |
| computeShowNoAttentionUpdate() { |
| return ( |
| this.isSendDisabled() || this.computeNewAttentionAccounts().length === 0 |
| ); |
| } |
| |
| computeDoNotUpdateMessage() { |
| if (!this.currentAttentionSet || !this.newAttentionSet) return ''; |
| if ( |
| this.isSendDisabled() || |
| areSetsEqual(this.currentAttentionSet, this.newAttentionSet) |
| ) { |
| return 'No changes to the attention set.'; |
| } |
| if (containsAll(this.currentAttentionSet, this.newAttentionSet)) { |
| return 'No additions to the attention set.'; |
| } |
| this.reporting.error( |
| 'computeDoNotUpdateMessage', |
| new Error( |
| 'computeDoNotUpdateMessage()' + |
| 'should not be called when users were added to the attention set.' |
| ) |
| ); |
| return ''; |
| } |
| |
| computeNewAttentionAccounts(): AccountInfo[] { |
| if ( |
| this.currentAttentionSet === undefined || |
| this.newAttentionSet === undefined |
| ) { |
| return []; |
| } |
| return [...this.newAttentionSet] |
| .filter(id => !this.currentAttentionSet.has(id)) |
| .map(id => this.findAccountById(id)) |
| .filter(account => !!account) as AccountInfo[]; |
| } |
| |
| findAccountById(userId: UserId) { |
| return this.allAccounts().find(r => getUserId(r) === userId); |
| } |
| |
| allAccounts() { |
| let allAccounts: (AccountInfoInput | GroupInfoInput)[] = []; |
| if (this.change && this.change.owner) allAccounts.push(this.change.owner); |
| if (this.uploader) allAccounts.push(this.uploader); |
| if (this.reviewers) allAccounts = [...allAccounts, ...this.reviewers]; |
| if (this.ccs) allAccounts = [...allAccounts, ...this.ccs]; |
| return removeServiceUsers(allAccounts.filter(isAccount)); |
| } |
| |
| computeUploader() { |
| if ( |
| !this.change?.current_revision || |
| !this.change?.revisions?.[this.change.current_revision] |
| ) { |
| this.uploader = undefined; |
| return; |
| } |
| const rev = this.change.revisions[this.change.current_revision]; |
| |
| if ( |
| !rev.uploader || |
| this.change?.owner._account_id === rev.uploader._account_id |
| ) { |
| this.uploader = undefined; |
| return; |
| } |
| this.uploader = rev.uploader; |
| } |
| |
| /** |
| * Generates a function to filter out reviewer/CC entries. When isCCs is |
| * truthy, the function filters out entries that already exist in this.ccs. |
| * When falsy, the function filters entries that exist in this.reviewers. |
| */ |
| filterReviewerSuggestionGenerator( |
| isCCs: boolean |
| ): (input: Suggestion) => boolean { |
| return suggestion => { |
| let entry: AccountInfo | GroupInfo; |
| if (isReviewerAccountSuggestion(suggestion)) { |
| entry = suggestion.account; |
| if (entry._account_id === this.change?.owner?._account_id) { |
| return false; |
| } |
| } else if (isReviewerGroupSuggestion(suggestion)) { |
| entry = suggestion.group; |
| } else { |
| this.reporting.error( |
| 'Reviewer Suggestion', |
| new Error(`Suggestion is neither account nor group: ${suggestion}`) |
| ); |
| return false; |
| } |
| |
| const key = getUserId(entry); |
| const finder = (entry: AccountInfo | GroupInfo) => |
| getUserId(entry) === key; |
| if (isCCs) { |
| return this.ccs.find(finder) === undefined; |
| } |
| return this.reviewers.find(finder) === undefined; |
| }; |
| } |
| |
| cancelTapHandler(e: Event) { |
| e.preventDefault(); |
| this.cancel(); |
| } |
| |
| async cancel() { |
| assertIsDefined(this.change, 'change'); |
| if (!this.change?.owner) throw new Error('missing required owner property'); |
| fireNoBubble(this, 'cancel', {}); |
| await this.patchsetLevelGrComment?.save(); |
| this.rebuildReviewerArrays(); |
| } |
| |
| private saveClickHandler(e: Event) { |
| e.preventDefault(); |
| this.submit(false); |
| } |
| |
| private sendClickHandler(e: Event) { |
| e.preventDefault(); |
| this.submit(this.canBeStarted); |
| } |
| |
| private submit(startReview?: boolean) { |
| if (startReview === undefined) { |
| startReview = this.isOwner && this.canBeStarted; |
| } |
| if (!this.ccsList?.submitEntryText()) { |
| // Do not proceed with the send if there is an invalid email entry in |
| // the text field of the CC entry. |
| return; |
| } |
| if (this.isSendDisabled()) { |
| fireAlert(this, EMPTY_REPLY_MESSAGE); |
| return; |
| } |
| return this.send(this.includeComments, startReview).catch(err => { |
| fireError(this, `Error submitting review ${err}`); |
| }); |
| } |
| |
| saveReview(review: ReviewInput, errFn?: ErrorCallback) { |
| assertIsDefined(this.change, 'change'); |
| assertIsDefined(this.latestPatchNum, 'latestPatchNum'); |
| return this.restApiService.saveChangeReview( |
| this.change._number, |
| this.latestPatchNum, |
| review, |
| errFn, |
| /* fetchDetail=*/ true |
| ); |
| } |
| |
| pendingConfirmationUpdated(reviewer: RawAccountInput | null) { |
| if (reviewer === null) { |
| this.reviewerConfirmationModal?.close(); |
| } else { |
| this.pendingConfirmationDetails = |
| this.ccPendingConfirmation || this.reviewerPendingConfirmation; |
| this.reviewerConfirmationModal?.showModal(); |
| } |
| } |
| |
| confirmPendingReviewer() { |
| this.reviewerConfirmationModal?.close(); |
| if (this.ccPendingConfirmation) { |
| this.ccsList?.confirmGroup(this.ccPendingConfirmation.group); |
| this.focusOn(FocusTarget.CCS); |
| return; |
| } |
| if (this.reviewerPendingConfirmation) { |
| this.reviewersList?.confirmGroup(this.reviewerPendingConfirmation.group); |
| this.focusOn(FocusTarget.REVIEWERS); |
| return; |
| } |
| this.reporting.error( |
| 'confirmPendingReviewer', |
| new Error('confirmPendingReviewer called without pending confirm') |
| ); |
| } |
| |
| cancelPendingReviewer() { |
| this.reviewerConfirmationModal?.close(); |
| this.ccPendingConfirmation = null; |
| this.reviewerPendingConfirmation = null; |
| |
| const target = this.ccPendingConfirmation |
| ? FocusTarget.CCS |
| : FocusTarget.REVIEWERS; |
| this.focusOn(target); |
| } |
| |
| handleAccountTextEntry() { |
| // When either of the account entries has input added to the autocomplete, |
| // it should trigger the save button to enable/ |
| // |
| // Note: if the text is removed, the save button will not get disabled. |
| this.reviewersMutated = true; |
| } |
| |
| private alreadyExists(ccs: AccountInput[], user: AccountInfoInput) { |
| return ccs |
| .filter(cc => isAccount(cc)) |
| .some(cc => getUserId(cc) === getUserId(user)); |
| } |
| |
| private isAlreadyReviewerOrCC(user: AccountInfo) { |
| return ( |
| this.alreadyExists(this.reviewers, user) || |
| this.alreadyExists(this._ccs, user) |
| ); |
| } |
| |
| getLabelScores(): GrLabelScores { |
| return this.labelScores || queryAndAssert(this, 'gr-label-scores'); |
| } |
| |
| _handleLabelsChanged() { |
| this.labelsChanged = |
| Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0; |
| } |
| |
| handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) { |
| this.reviewers = [...e.detail.value]; |
| this.reviewersMutated = true; |
| } |
| |
| handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) { |
| this.ccs = [...e.detail.value]; |
| this.reviewersMutated = true; |
| } |
| |
| handleReviewersConfirmationChanged( |
| e: ValueChangedEvent<SuggestedReviewerGroupInfo | null> |
| ) { |
| this.reviewerPendingConfirmation = e.detail.value; |
| } |
| |
| handleCcsConfirmationChanged( |
| e: ValueChangedEvent<SuggestedReviewerGroupInfo | null> |
| ) { |
| this.ccPendingConfirmation = e.detail.value; |
| } |
| |
| _reload() { |
| this.getChangeModel().navigateToChangeResetReload(); |
| this.cancel(); |
| } |
| |
| computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) { |
| if (commentEditing) { |
| return ButtonTooltips.DISABLED_COMMENT_EDITING; |
| } |
| return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND; |
| } |
| |
| computeSavingLabelClass(savingComments: boolean) { |
| return savingComments ? 'saving' : ''; |
| } |
| |
| // visible for testing |
| isSendDisabled() { |
| if ( |
| this.canBeStarted === undefined || |
| this.patchsetLevelDraftMessage === undefined || |
| this.reviewersMutated === undefined || |
| this.labelsChanged === undefined || |
| this.includeComments === undefined || |
| this.disabled === undefined || |
| this.commentEditing === undefined || |
| this.change?.labels === undefined || |
| this.account === undefined |
| ) { |
| return undefined; |
| } |
| if (this.commentEditing || this.disabled) { |
| return true; |
| } |
| if (this.canBeStarted === true) { |
| return false; |
| } |
| const existingVote = Object.values(this.change.labels).some( |
| label => |
| isDetailedLabelInfo(label) && getApprovalInfo(label, this.account!) |
| ); |
| const revotingOrNewVote = this.labelsChanged || existingVote; |
| const hasDrafts = |
| (this.includeComments && this.draftCommentThreads.length > 0) || |
| this.patchsetLevelDraftMessage.length > 0; |
| return ( |
| !hasDrafts && |
| !this.patchsetLevelDraftMessage.length && |
| !this.reviewersMutated && |
| !revotingOrNewVote |
| ); |
| } |
| |
| computePatchSetWarning() { |
| let str = `Patch ${this.latestPatchNum} is not latest.`; |
| if (this.labelsChanged) { |
| str += ' Voting may have no effect.'; |
| } |
| return str; |
| } |
| |
| setPluginMessage(message: string) { |
| this.pluginMessage = message; |
| } |
| |
| getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) { |
| if (!change) return; |
| const provider = new GrReviewerSuggestionsProvider( |
| this.restApiService, |
| ReviewerState.REVIEWER, |
| this.serverConfig, |
| this.isLoggedIn, |
| change |
| ); |
| return provider; |
| } |
| |
| getCcSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) { |
| if (!change) return; |
| const provider = new GrReviewerSuggestionsProvider( |
| this.restApiService, |
| ReviewerState.CC, |
| this.serverConfig, |
| this.isLoggedIn, |
| change |
| ); |
| return provider; |
| } |
| |
| reportAttentionSetChanges( |
| modified: boolean, |
| addedSet?: AttentionSetInput[], |
| removedSet?: AttentionSetInput[] |
| ) { |
| const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED']; |
| const ownerId = |
| (this.change && this.change.owner && this.change.owner._account_id) || -1; |
| const selfId = (this.account && this.account._account_id) || -1; |
| for (const added of addedSet || []) { |
| const addedId = added.user; |
| const self = addedId === selfId ? '_SELF' : ''; |
| const role = addedId === ownerId ? 'OWNER' : '_REVIEWER'; |
| actions.push('ADD' + self + role); |
| } |
| for (const removed of removedSet || []) { |
| const removedId = removed.user; |
| const self = removedId === selfId ? '_SELF' : ''; |
| const role = removedId === ownerId ? 'OWNER' : '_REVIEWER'; |
| actions.push('REMOVE' + self + role); |
| } |
| this.reporting.reportInteraction('attention-set-actions', {actions}); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-reply-dialog': GrReplyDialog; |
| } |
| interface HTMLElementEventMap { |
| /** Fired when the user presses the cancel button. */ |
| // prettier-ignore |
| 'cancel': CustomEvent<{}>; |
| /** |
| * Fires when the reply dialog believes that the server side diff drafts |
| * have been updated and need to be refreshed. |
| */ |
| 'comment-refresh': CustomEvent<{}>; |
| /** Fired when a reply is successfully sent. */ |
| // prettier-ignore |
| 'send': CustomEvent<{}>; |
| /** Fires when the state of the send button (enabled/disabled) changes. */ |
| 'send-disabled-changed': CustomEvent<{}>; |
| } |
| } |