diff options
Diffstat (limited to 'polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts')
-rw-r--r-- | polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts | 1750 |
1 files changed, 1230 insertions, 520 deletions
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts index b8932e53e0..3e766c91c2 100644 --- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts @@ -16,6 +16,8 @@ */ import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea'; 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-textarea/gr-textarea'; import '../../shared/gr-button/gr-button'; @@ -25,13 +27,11 @@ 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 {PolymerElement} from '@polymer/polymer/polymer-element'; -import {htmlTemplate} from './gr-reply-dialog_html'; import { GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES, } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider'; -import {appContext} from '../../../services/app-context'; +import {getAppContext} from '../../../services/app-context'; import { ChangeStatus, DraftsAction, @@ -46,14 +46,14 @@ import { } from '../../../utils/account-util'; import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer'; import {TargetElement} from '../../../api/plugin'; -import {customElement, observe, property} from '@polymer/decorators'; import {FixIronA11yAnnouncer} from '../../../types/types'; import { AccountAddition, AccountInfoInput, + AccountInput, + AccountInputDetail, GrAccountList, GroupInfoInput, - GroupObjectInput, RawAccountInput, } from '../../shared/gr-account-list/gr-account-list'; import { @@ -62,32 +62,24 @@ import { AttentionSetInput, ChangeInfo, CommentInput, - EmailAddress, - GroupId, GroupInfo, isAccount, isDetailedLabelInfo, isReviewerAccountSuggestion, isReviewerGroupSuggestion, - LabelNameToValueMap, ParsedJSON, PatchSetNum, - ProjectInfo, ReviewerInput, - Reviewers, ReviewInput, ReviewResult, ServerInfo, + SuggestedReviewerGroupInfo, Suggestion, } 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 { - PolymerDeepPropertyChange, - PolymerSpliceChange, -} from '@polymer/polymer/interfaces'; -import { areSetsEqual, assertIsDefined, containsAll, @@ -116,6 +108,16 @@ import {StorageLocation} from '../../../services/storage/gr-storage'; import {Interaction, Timing} from '../../../constants/reporting'; import {getReplyByReason} from '../../../utils/attention-set-util'; import {addShortcut, Key, Modifier} from '../../../utils/dom-util'; +import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api'; +import {resolve} from '../../../models/dependency'; +import {changeModelToken} from '../../../models/change/change-model'; +import {ConfigInfo, LabelNameToValuesMap} from '../../../api/rest-api'; +import {css, html, PropertyValues, LitElement} from 'lit'; +import {sharedStyles} from '../../../styles/shared-styles'; +import {when} from 'lit/directives/when'; +import {classMap} from 'lit/directives/class-map'; +import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events'; +import {customElement, property, state, query} from 'lit/decorators'; const STORAGE_DEBOUNCE_INTERVAL_MS = 400; @@ -151,24 +153,8 @@ const ButtonTooltips = { const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.'; -export interface GrReplyDialog { - $: { - reviewers: GrAccountList; - ccs: GrAccountList; - cancelButton: GrButton; - sendButton: GrButton; - labelScores: GrLabelScores; - textarea: GrTextarea; - reviewerConfirmationOverlay: GrOverlay; - }; -} - @customElement('gr-reply-dialog') -export class GrReplyDialog extends PolymerElement { - static get template() { - return htmlTemplate; - } - +export class GrReplyDialog extends LitElement { /** * Fired when a reply is successfully sent. * @@ -215,9 +201,9 @@ export class GrReplyDialog extends PolymerElement { FocusTarget = FocusTarget; - private readonly reporting = appContext.reportingService; + private readonly reporting = getAppContext().reportingService; - private readonly changeService = appContext.changeService; + private readonly getChangeModel = resolve(this, changeModelToken); @property({type: Object}) change?: ChangeInfo; @@ -228,151 +214,437 @@ export class GrReplyDialog extends PolymerElement { @property({type: Boolean}) canBeStarted = false; - @property({type: Boolean, reflectToAttribute: true}) + @property({type: Boolean, reflect: true}) disabled = false; - @property({ - type: Boolean, - computed: '_computeHasDrafts(draft, draftCommentThreads.*)', - }) - hasDrafts = false; - - @property({type: String, observer: '_draftChanged'}) - draft = ''; - - @property({type: String}) - quote = ''; + @property({type: Array}) + draftCommentThreads: CommentThread[] | undefined; @property({type: Object}) - filterReviewerSuggestion: (input: Suggestion) => boolean; + permittedLabels?: LabelNameToValuesMap; @property({type: Object}) - filterCCSuggestion: (input: Suggestion) => boolean; + projectConfig?: ConfigInfo; @property({type: Object}) - permittedLabels?: LabelNameToValueMap; + serverConfig?: ServerInfo; - @property({type: Object}) - projectConfig?: ProjectInfo; + @query('#reviewers') reviewersList?: GrAccountList; - @property({type: Object}) - serverConfig?: ServerInfo; + @query('#ccs') ccsList?: GrAccountList; - @property({type: String}) + @query('#cancelButton') cancelButton?: GrButton; + + @query('#sendButton') sendButton?: GrButton; + + @query('#labelScores') labelScores?: GrLabelScores; + + @query('#textarea') textarea?: GrTextarea; + + @query('#reviewerConfirmationOverlay') + reviewerConfirmationOverlay?: GrOverlay; + + @state() + draft = ''; + + @state() + filterReviewerSuggestion: (input: Suggestion) => boolean; + + @state() + filterCCSuggestion: (input: Suggestion) => boolean; + + @state() knownLatestState?: LatestPatchState; - @property({type: Boolean}) + @state() underReview = true; - @property({type: Object}) - _account?: AccountInfo; + @state() + account?: AccountInfo; - @property({type: Array}) - _ccs: (AccountInfo | GroupInfo)[] = []; + @state() + ccs: (AccountInfoInput | GroupInfoInput)[] = []; - @property({type: Number}) - _attentionCcsCount = 0; + @state() + attentionCcsCount = 0; - @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'}) - _ccPendingConfirmation: GroupObjectInput | null = null; + @state() + ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null; - @property({ - type: String, - computed: '_computeMessagePlaceholder(canBeStarted)', - }) - _messagePlaceholder?: string; + @state() + messagePlaceholder?: string; - @property({type: Object}) - _owner?: AccountInfo; + @state() + owner?: AccountInfo; - @property({type: Object, computed: '_computeUploader(change)'}) - _uploader?: AccountInfo; + @state() + uploader?: AccountInfo; - @property({type: Object}) - _pendingConfirmationDetails: GroupObjectInput | null = null; + @state() + pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null; - @property({type: Boolean}) - _includeComments = true; + @state() + includeComments = true; - @property({type: Array}) - _reviewers: (AccountInfo | GroupInfo)[] = []; + @state() reviewers: AccountInput[] = []; - @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'}) - _reviewerPendingConfirmation: GroupObjectInput | null = null; + @state() + reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null; - @property({type: Boolean, observer: '_handleHeightChanged'}) - _previewFormatting = false; + @state() + previewFormatting = false; - @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'}) - _sendButtonLabel?: string; + @state() + sendButtonLabel?: string; - @property({type: Boolean}) - _savingComments = false; + @state() + savingComments = false; - @property({type: Boolean}) - _reviewersMutated = 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. */ - @property({type: Boolean}) - _labelsChanged = false; + @state() + labelsChanged = false; - @property({type: String}) - readonly _saveTooltip: string = ButtonTooltips.SAVE; + @state() + readonly saveTooltip: string = ButtonTooltips.SAVE; - @property({type: String}) - _pluginMessage = ''; + @state() + pluginMessage = ''; - @property({type: Boolean}) - _commentEditing = false; + @state() + commentEditing = false; - @property({type: Boolean}) - _attentionExpanded = false; + @state() + attentionExpanded = false; - @property({type: Object}) - _currentAttentionSet: Set<AccountId> = new Set(); + @state() + currentAttentionSet: Set<AccountId> = new Set(); - @property({type: Object}) - _newAttentionSet: Set<AccountId> = new Set(); - - @property({ - type: Boolean, - computed: - '_computeSendButtonDisabled(canBeStarted, ' + - 'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' + - '_includeComments, disabled, _commentEditing, change, _account)', - observer: '_sendDisabledChanged', - }) - _sendDisabled?: boolean; - - @property({type: Array, observer: '_handleHeightChanged'}) - draftCommentThreads: CommentThread[] | undefined; + @state() + newAttentionSet: Set<AccountId> = new Set(); - @property({type: Boolean}) - _isResolvedPatchsetLevelComment = true; + @state() + sendDisabled?: boolean; + + @state() + isResolvedPatchsetLevelComment = true; - @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'}) - _allReviewers: (AccountInfo | GroupInfo)[] = []; + @state() + allReviewers: (AccountInfo | GroupInfo)[] = []; - private readonly restApiService = appContext.restApiService; + private readonly restApiService: RestApiService = + getAppContext().restApiService; - private readonly storage = appContext.storageService; + private readonly storage = getAppContext().storageService; - private readonly jsAPI = appContext.jsApiService; + private readonly jsAPI = getAppContext().jsApiService; - private storeTask?: DelayedTask; + storeTask?: DelayedTask; /** Called in disconnectedCallback. */ private cleanups: (() => void)[] = []; + static override styles = [ + sharedStyles, + css` + :host { + background-color: var(--dialog-background-color); + display: block; + max-height: 90vh; + } + :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; + } + #reviewerConfirmationOverlay { + 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, + #textarea, + gr-endpoint-decorator[name='reply-text'] { + display: flex; + width: 100%; + } + .newReplyDialog .textareaContainer, + #textarea, + gr-endpoint-decorator[name='reply-text'] { + display: block; + width: unset; + font-family: var(--monospace-font-family); + font-size: var(--font-size-code); + line-height: calc(var(--font-size-code) + var(--spacing-s)); + font-weight: var(--font-weight-normal); + } + .newReplyDialog#textarea { + padding: var(--spacing-m); + } + gr-endpoint-decorator[name='reply-text'] { + flex-direction: column; + } + #textarea { + flex: 1; + } + .previewContainer { + border-top: none; + } + .previewContainer gr-formatted-text { + background: var(--table-header-background-color); + padding: var(--spacing-l); + } + #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; + } + .preview-formatting { + margin-left: var(--spacing-m); + } + .attention-icon { + width: 14px; + height: 14px; + vertical-align: top; + position: relative; + top: 3px; + --iron-icon-height: 24px; + --iron-icon-width: 24px; + } + .attention .edit-attention-button { + vertical-align: top; + --gr-button-padding: 0px 4px; + } + .attention .edit-attention-button iron-icon { + color: inherit; + } + .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(--assignee-highlight-color); + } + .attentionTip div iron-icon { + margin-right: var(--spacing-s); + } + .patchsetLevelContainer { + width: 80ch; + 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); + } + .labelContainer { + padding-left: var(--spacing-m); + padding-bottom: var(--spacing-m); + } + `, + ]; + + override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('draft')) { + this.draftChanged(changedProperties.get('draft') as string); + } + if (changedProperties.has('ccPendingConfirmation')) { + this.pendingConfirmationUpdated(this.ccPendingConfirmation); + } + if (changedProperties.has('reviewerPendingConfirmation')) { + this.pendingConfirmationUpdated(this.reviewerPendingConfirmation); + } + if (changedProperties.has('change')) { + this.computeUploader(); + this.changeUpdated(); + } + if (changedProperties.has('canBeStarted')) { + this.computeMessagePlaceholder(); + this.computeSendButtonLabel(); + } + if (changedProperties.has('reviewFormatting')) { + this.handleHeightChanged(); + } + if (changedProperties.has('draftCommentThreads')) { + this.handleHeightChanged(); + } + if (changedProperties.has('reviewers')) { + this.computeAllReviewers(); + } + if (changedProperties.has('sendDisabled')) { + this.sendDisabledChanged(); + } + if (changedProperties.has('attentionExpanded')) { + this.onAttentionExpandedChange(); + } + if ( + changedProperties.has('account') || + changedProperties.has('reviewers') || + changedProperties.has('ccs') || + changedProperties.has('change') || + changedProperties.has('draftCommentThreads') || + changedProperties.has('includeComments') || + changedProperties.has('labelsChanged') || + changedProperties.has('draft') + ) { + this.computeNewAttention(); + } + } + constructor() { super(); this.filterReviewerSuggestion = - this._filterReviewerSuggestionGenerator(false); - this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true); + this.filterReviewerSuggestionGenerator(false); + this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true); + this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this); } override connectedCallback() { @@ -380,23 +652,23 @@ export class GrReplyDialog extends PolymerElement { ( IronA11yAnnouncer as unknown as FixIronA11yAnnouncer ).requestAvailability(); - this._getAccount().then(account => { - if (account) this._account = account; + this.restApiService.getAccount().then(account => { + if (account) this.account = account; }); this.cleanups.push( addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ => - this._submit() + this.submit() ) ); this.cleanups.push( addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ => - this._submit() + this.submit() ) ); this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel())); this.addEventListener('comment-editing-changed', e => { - this._commentEditing = (e as CustomEvent).detail; + this.commentEditing = (e as CustomEvent).detail; }); // Plugins on reply-reviewers endpoint can take advantage of these @@ -405,21 +677,17 @@ export class GrReplyDialog extends PolymerElement { this.addEventListener('add-reviewer', e => { // Only support account type, see more from: // elements/shared/gr-account-list/gr-account-list.js#addAccountItem - this.$.reviewers.addAccountItem({ + this.reviewersList?.addAccountItem({ account: (e as CustomEvent).detail.reviewer, + count: 1, }); }); this.addEventListener('remove-reviewer', e => { - this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer); + this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer); }); } - override ready() { - super.ready(); - this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this); - } - override disconnectedCallback() { this.storeTask?.cancel(); for (const cleanup of this.cleanups) cleanup(); @@ -427,117 +695,602 @@ export class GrReplyDialog extends PolymerElement { super.disconnectedCallback(); } - open(focusTarget?: FocusTarget) { + override render() { + if (!this.change) return; + this.sendDisabled = this.computeSendButtonDisabled(); + 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.allReviewers}> + </gr-endpoint-param> + ${this.renderReviewerList()} + <gr-endpoint-slot name="below"></gr-endpoint-slot> + </gr-endpoint-decorator> + ${this.renderCCList()} ${this.renderReviewConfirmation()} + </section> + <section class="labelsContainer">${this.renderLabels()}</section> + <section class="newReplyDialog textareaContainer"> + ${this.renderReplyText()} + </section> + ${when( + this.previewFormatting, + () => html` + <section class="previewContainer"> + <gr-formatted-text + .content=${this.draft} + .config=${this.projectConfig?.commentlinks} + ></gr-formatted-text> + </section> + ` + )} + ${this.renderDraftsSection()} + <div class="stickyBottom newReplyDialog"> + <gr-endpoint-decorator name="reply-bottom"> + <gr-endpoint-param + name="change" + .value=${this.change} + ></gr-endpoint-param> + ${this.renderAttentionSummarySection()} + ${this.renderAttentionDetailsSection()} + <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.getAccountListCopy(this.reviewers)} + @account-added=${this.accountAdded} + @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.getAccountListCopy(this.ccs)} + @account-added=${this.accountAdded} + @accounts-changed=${this.handleCcsChanged} + .removableValues=${this.change?.removable_reviewers} + .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` + <gr-overlay + id="reviewerConfirmationOverlay" + @iron-overlay-canceled=${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> + </gr-overlay> + `; + } + + 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 renderReplyText() { + if (!this.change) return; + return html` + <div + class=${classMap({ + patchsetLevelContainer: true, + [this.getUnresolvedPatchsetLevelClass( + this.isResolvedPatchsetLevelComment + )]: true, + })} + > + <gr-endpoint-decorator name="reply-text"> + <gr-textarea + id="textarea" + class="message newReplyDialog" + .autocomplete=${'on'} + .placeholder=${this.messagePlaceholder} + monospace + ?disabled=${this.disabled} + .rows=${4} + .text=${this.draft} + @bind-value-changed=${(e: BindValueChangeEvent) => { + this.draft = e.detail.value; + this.handleHeightChanged(); + }} + > + </gr-textarea> + <gr-endpoint-param name="change" .value=${this.change}> + </gr-endpoint-param> + </gr-endpoint-decorator> + <div class="labelContainer"> + <label> + <input + id="resolvedPatchsetLevelCommentCheckbox" + type="checkbox" + ?checked=${this.isResolvedPatchsetLevelComment} + @change=${this.handleResolvedPatchsetLevelCommentCheckboxChanged} + /> + Resolved + </label> + <label class="preview-formatting"> + <input + type="checkbox" + ?checked=${this.previewFormatting} + @change=${this.handlePreviewFormattingChanged} + /> + Preview formatting + </label> + </div> + </div> + `; + } + + private renderDraftsSection() { + if (this.computeHideDraftList(this.draftCommentThreads)) 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(this.draftCommentThreads)}</label + > + </div> + ${when( + this.includeComments, + () => html` + <gr-thread-list + id="commentList" + .threads=${this.draftCommentThreads!} + hide-dropdown + > + </gr-thread-list> + ` + )} + <span + id="savingLabel" + class=${this.computeSavingLabelClass(this.savingComments)} + > + Saving comments... + </span> + </section> + `; + } + + private renderAttentionSummarySection() { + if (this.attentionExpanded) return; + 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> + ` + )} + ` + )} + <gr-tooltip-content + has-tooltip + title=${this.computeAttentionButtonTitle()} + > + <gr-button + class="edit-attention-button" + @click=${this.handleAttentionModify} + ?disabled=${this.sendDisabled} + link + position-below + data-label="Edit" + data-action-type="change" + data-action-key="edit" + role="button" + tabindex="0" + > + <iron-icon icon="gr-icons:edit"></iron-icon> + Modify + </gr-button> + </gr-tooltip-content> + </div> + <div> + <a + href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html" + target="_blank" + > + <iron-icon + icon="gr-icons:help-outline" + title="read documentation" + ></iron-icon> + </a> + </div> + </div> + </section> + `; + } + + private renderAttentionDetailsSection() { + if (!this.attentionExpanded) return; + return html` + <section class="attention-detail"> + <div class="attentionDetailsTitle"> + <div> + <span>Modify attention to</span> + </div> + <div></div> + <div> + <a + href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html" + target="_blank" + > + <iron-icon + icon="gr-icons:help-outline" + title="read documentation" + ></iron-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.owner} + ?forceAttention=${this.computeHasNewAttention(this.owner)} + .selected=${this.computeHasNewAttention(this.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"> + ${this.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"> + ${this.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( + this.account, + this.owner, + this.currentAttentionSet, + this.newAttentionSet + ), + () => html` + <div class="attentionTip"> + <iron-icon + class="pointer" + icon="gr-icons:lightbulb-outline" + ></iron-icon> + 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.patchNum} 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.sendDisabled} + class="action send" + @click=${this.sendTapHandler} + >${this.sendButtonLabel} + </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, quote?: string) { assertIsDefined(this.change, 'change'); this.knownLatestState = LatestPatchState.CHECKING; - this.changeService.fetchChangeUpdates(this.change).then(result => { - this.knownLatestState = result.isLatest - ? LatestPatchState.LATEST - : LatestPatchState.NOT_LATEST; - }); + this.getChangeModel() + .fetchChangeUpdates(this.change) + .then(result => { + this.knownLatestState = result.isLatest + ? LatestPatchState.LATEST + : LatestPatchState.NOT_LATEST; + }); - this._focusOn(focusTarget); - if (this.quote && this.quote.length) { - // If a reply quote has been provided, use it and clear the property. - this.draft = this.quote; - this.quote = ''; + this.focusOn(focusTarget); + if (quote?.length) { + // If a reply quote has been provided, use it. + this.draft = quote; } else { // Otherwise, check for an unsaved draft in localstorage. - this.draft = this._loadStoredDraft(); + this.draft = this.loadStoredDraft(); } if (this.restApiService.hasPendingDiffDrafts()) { - this._savingComments = true; + this.savingComments = true; this.restApiService.awaitPendingDiffDrafts().then(() => { fireEvent(this, 'comment-refresh'); - this._savingComments = false; + this.savingComments = false; }); } } - _computeHasDrafts( - draft: string, - draftCommentThreads: PolymerDeepPropertyChange< - CommentThread[] | undefined, - CommentThread[] | undefined - > - ) { - if (draftCommentThreads.base === undefined) return false; - return draft.length > 0 || draftCommentThreads.base.length > 0; + hasDrafts() { + if (this.draftCommentThreads === undefined) return false; + return this.draft.length > 0 || this.draftCommentThreads.length > 0; } override focus() { - this._focusOn(FocusTarget.ANY); + this.focusOn(FocusTarget.ANY); } getFocusStops() { - const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton; + const end = this.sendDisabled ? this.cancelButton : this.sendButton; + if (!this.reviewersList?.focusStart || !end) return undefined; return { - start: this.$.reviewers.focusStart, + start: this.reviewersList.focusStart, end, }; } - setLabelValue(label: string, value: string) { - const selectorEl = this.getLabelScores().shadowRoot?.querySelector( - `gr-label-score-row[name="${label}"]` - ); - if (!selectorEl) { - return; - } - (selectorEl as GrLabelScoreRow).setSelectedValue(value); + private handleResolvedPatchsetLevelCommentCheckboxChanged(e: Event) { + if (!(e.target instanceof HTMLInputElement)) return; + this.isResolvedPatchsetLevelComment = e.target.checked; } - getLabelValue(label: string) { - const selectorEl = this.getLabelScores().shadowRoot?.querySelector( - `gr-label-score-row[name="${label}"]` - ); - if (!selectorEl) { - return null; - } - - return (selectorEl as GrLabelScoreRow).selectedValue; + private handlePreviewFormattingChanged(e: Event) { + if (!(e.target instanceof HTMLInputElement)) return; + this.previewFormatting = e.target.checked; } - @observe('_ccs.splices') - _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) { - this._reviewerTypeChanged(splices, ReviewerType.CC); + private handleIncludeCommentsChanged(e: Event) { + if (!(e.target instanceof HTMLInputElement)) return; + this.includeComments = e.target.checked; } - @observe('_reviewers.splices') - _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) { - this._reviewerTypeChanged(splices, ReviewerType.REVIEWER); + setLabelValue(label: string, value: string): void { + const selectorEl = + this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>( + `gr-label-score-row[name="${label}"]` + ); + selectorEl?.setSelectedValue(value); } - _reviewerTypeChanged( - splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>, - reviewerType: ReviewerType - ) { - if (splices && splices.indexSplices) { - this._reviewersMutated = true; - let key: AccountId | EmailAddress | GroupId | undefined; - let index; - let account; + getLabelValue(label: string) { + const selectorEl = + this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>( + `gr-label-score-row[name="${label}"]` + ); + return selectorEl?.selectedValue; + } + + accountAdded(e: CustomEvent<AccountInputDetail>) { + const account = e.detail.account; + const key = accountOrGroupKey(account); + const reviewerType = + (e.target as GrAccountList).getAttribute('id') === 'ccs' + ? ReviewerType.CC + : ReviewerType.REVIEWER; + const isReviewer = ReviewerType.REVIEWER === reviewerType; + const array = isReviewer ? this.ccs : this.reviewers; + const index = array.findIndex( + reviewer => accountOrGroupKey(reviewer) === key + ); + if (index >= 0) { // Remove any accounts that already exist as a CC for reviewer // or vice versa. - const isReviewer = ReviewerType.REVIEWER === reviewerType; - for (const splice of splices.indexSplices) { - for (let i = 0; i < splice.addedCount; i++) { - account = splice.object[splice.index + i]; - key = accountOrGroupKey(account); - const array = isReviewer ? this._ccs : this._reviewers; - index = array.findIndex( - account => accountOrGroupKey(account) === key - ); - if (index >= 0) { - this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1); - 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); - } - } - } + array.splice(index, 1); + 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); } } @@ -557,33 +1310,27 @@ export class GrReplyDialog extends PolymerElement { reviewers.push(reviewer); }); }; - addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER); - addToReviewInput(this.$.ccs.additions(), ReviewerState.CC); + addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER); + addToReviewInput(this.ccsList!.additions(), ReviewerState.CC); addToReviewInput( - this.$.reviewers.removals().filter( + this.reviewersList!.removals().filter( r => isReviewerOrCC(change, r) && // ignore removal from reviewer request if being added to CC - !this.$.ccs - .additions() - .some( - account => - mapReviewer(account).reviewer === mapReviewer(r).reviewer - ) + !this.ccsList!.additions().some( + account => mapReviewer(account).reviewer === mapReviewer(r).reviewer + ) ), ReviewerState.REMOVED ); addToReviewInput( - this.$.ccs.removals().filter( + this.ccsList!.removals().filter( r => isReviewerOrCC(change, r) && // ignore removal from CC request if being added as reviewer - !this.$.reviewers - .additions() - .some( - account => - mapReviewer(account).reviewer === mapReviewer(r).reviewer - ) + !this.reviewersList!.additions().some( + account => mapReviewer(account).reviewer === mapReviewer(r).reviewer + ) ), ReviewerState.REMOVED ); @@ -605,23 +1352,23 @@ export class GrReplyDialog extends PolymerElement { reviewInput.ready = true; } - const reason = getReplyByReason(this._account, this.serverConfig); + const reason = getReplyByReason(this.account, this.serverConfig); reviewInput.ignore_automatic_attention_set_rules = true; reviewInput.add_to_attention_set = []; - for (const user of this._newAttentionSet) { - if (!this._currentAttentionSet.has(user)) { + for (const user of this.newAttentionSet) { + if (!this.currentAttentionSet.has(user)) { reviewInput.add_to_attention_set.push({user, reason}); } } reviewInput.remove_from_attention_set = []; - for (const user of this._currentAttentionSet) { - if (!this._newAttentionSet.has(user)) { + for (const user of this.currentAttentionSet) { + if (!this.newAttentionSet.has(user)) { reviewInput.remove_from_attention_set.push({user, reason}); } } this.reportAttentionSetChanges( - this._attentionExpanded, + this.attentionExpanded, reviewInput.add_to_attention_set, reviewInput.remove_from_attention_set ); @@ -629,7 +1376,7 @@ export class GrReplyDialog extends PolymerElement { if (this.draft) { const comment: CommentInput = { message: this.draft, - unresolved: !this._isResolvedPatchsetLevelComment, + unresolved: !this.isResolvedPatchsetLevelComment, }; reviewInput.comments = { [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment], @@ -640,8 +1387,8 @@ export class GrReplyDialog extends PolymerElement { reviewInput.reviewers = this.computeReviewers(this.change); this.disabled = true; - const errFn = (r?: Response | null) => this._handle400Error(r); - return this._saveReview(reviewInput, errFn) + const errFn = (r?: Response | null) => this.handle400Error(r); + return this.saveReview(reviewInput, errFn) .then(response => { if (!response) { // Null or undefined response indicates that an error handler @@ -654,7 +1401,7 @@ export class GrReplyDialog extends PolymerElement { } this.draft = ''; - this._includeComments = true; + this.includeComments = true; this.dispatchEvent( new CustomEvent('send', { composed: true, @@ -674,31 +1421,31 @@ export class GrReplyDialog extends PolymerElement { }); } - _focusOn(section?: FocusTarget) { + focusOn(section?: FocusTarget) { // Safeguard- always want to focus on something. if (!section || section === FocusTarget.ANY) { - section = this._chooseFocusTarget(); + section = this.chooseFocusTarget(); } if (section === FocusTarget.BODY) { const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea'); setTimeout(() => textarea.getNativeTextarea().focus()); } else if (section === FocusTarget.REVIEWERS) { - const reviewerEntry = this.$.reviewers.focusStart; - setTimeout(() => reviewerEntry.focus()); + const reviewerEntry = this.reviewersList?.focusStart; + setTimeout(() => reviewerEntry?.focus()); } else if (section === FocusTarget.CCS) { - const ccEntry = this.$.ccs.focusStart; - setTimeout(() => ccEntry.focus()); + const ccEntry = this.ccsList?.focusStart; + setTimeout(() => ccEntry?.focus()); } } - _chooseFocusTarget() { + chooseFocusTarget() { // If we are the owner and the reviewers field is empty, focus on that. if ( - this._account && + this.account && this.change && this.change.owner && - this._account._account_id === this.change.owner._account_id && - (!this._reviewers || this._reviewers.length === 0) + this.account._account_id === this.change.owner._account_id && + (!this.reviewers || this.reviewers?.length === 0) ) { return FocusTarget.REVIEWERS; } @@ -707,15 +1454,15 @@ export class GrReplyDialog extends PolymerElement { return FocusTarget.BODY; } - _isOwner(account?: AccountInfo, change?: ChangeInfo) { + isOwner(account?: AccountInfo, change?: ChangeInfo) { if (!account || !change || !change.owner) return false; return account._account_id === change.owner._account_id; } - _handle400Error(r?: Response | null) { + 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 + // 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 @@ -750,45 +1497,42 @@ export class GrReplyDialog extends PolymerElement { }); } - _computeHideDraftList(draftCommentThreads?: CommentThread[]) { + computeHideDraftList(draftCommentThreads?: CommentThread[]) { return !draftCommentThreads || draftCommentThreads.length === 0; } - _computeDraftsTitle(draftCommentThreads?: CommentThread[]) { + computeDraftsTitle(draftCommentThreads?: CommentThread[]) { const total = draftCommentThreads ? draftCommentThreads.length : 0; return pluralize(total, 'Draft'); } - _computeMessagePlaceholder(canBeStarted: boolean) { - return canBeStarted + computeMessagePlaceholder() { + this.messagePlaceholder = this.canBeStarted ? 'Add a note for your reviewers...' : 'Say something nice...'; } - @observe('change.reviewers.*', 'change.owner') - _changeUpdated( - changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>, - owner: AccountInfo - ) { - if (changeRecord === undefined || owner === undefined) return; - this._rebuildReviewerArrays(changeRecord.base, owner); + changeUpdated() { + if (this.change === undefined) return; + this.rebuildReviewerArrays(); } - _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) { - this._owner = owner; + rebuildReviewerArrays() { + if (!this.change?.owner || !this.change?.reviewers) return; + this.owner = this.change.owner; const reviewers = []; const ccs = []; - if (changeReviewers) { - for (const key of Object.keys(changeReviewers)) { + if (this.change.reviewers) { + for (const key of Object.keys(this.change.reviewers)) { if (key !== 'REVIEWER' && key !== 'CC') { this.reporting.error(new Error(`Unexpected reviewer state: ${key}`)); continue; } - if (!changeReviewers[key]) continue; - for (const entry of changeReviewers[key]!) { - if (entry._account_id === owner._account_id) { + if (!this.change.reviewers[key]) continue; + for (const entry of this.change.reviewers[key]!) { + if (entry._account_id === this.owner._account_id) { continue; } switch (key) { @@ -803,172 +1547,140 @@ export class GrReplyDialog extends PolymerElement { } } - this._ccs = ccs; - this._reviewers = reviewers; + this.ccs = ccs; + this.reviewers = reviewers; } - _handleAttentionModify() { - this._attentionExpanded = true; + handleAttentionModify() { + this.attentionExpanded = true; } - @observe('_attentionExpanded') - _onAttentionExpandedChange() { + onAttentionExpandedChange() { // If the attention-detail section is expanded without dispatching this // event, then the dialog may expand beyond the screen's bottom border. fireEvent(this, 'iron-resize'); } - _showAttentionSummary(attentionExpanded?: boolean) { - return !attentionExpanded; - } - - _showAttentionDetails(attentionExpanded?: boolean) { - return attentionExpanded; - } - - _computeAttentionButtonTitle(sendDisabled?: boolean) { + computeAttentionButtonTitle(sendDisabled?: boolean) { return sendDisabled ? 'Modify the attention set by adding a comment or use the account ' + 'hovercard in the change page.' : 'Edit attention set changes'; } - _handleAttentionClick(e: Event) { + handleAttentionClick(e: Event) { const id = (e.target as GrAccountChip)?.account?._account_id; if (!id) return; - const selfId = (this._account && this._account._account_id) || -1; + const selfId = (this.account && this.account._account_id) || -1; const ownerId = (this.change && this.change.owner && this.change.owner._account_id) || -1; const self = id === selfId ? '_SELF' : ''; - const role = id === ownerId ? '_OWNER' : '_REVIEWER'; + const role = id === ownerId ? 'OWNER' : '_REVIEWER'; - if (this._newAttentionSet.has(id)) { - this._newAttentionSet.delete(id); + if (this.newAttentionSet.has(id)) { + this.newAttentionSet.delete(id); this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, { action: `REMOVE${self}${role}`, }); } else { - this._newAttentionSet.add(id); + this.newAttentionSet.add(id); this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, { action: `ADD${self}${role}`, }); } - // Ensure that Polymer picks up the change. - this._newAttentionSet = new Set(this._newAttentionSet); + this.requestUpdate(); } - _computeHasNewAttention( - account?: AccountInfo, - newAttention?: Set<AccountId> - ) { - return ( - newAttention && + computeHasNewAttention(account?: AccountInfo) { + return !!( account && account._account_id && - newAttention.has(account._account_id) + this.newAttentionSet?.has(account._account_id) ); } - @observe( - '_account', - '_reviewers.*', - '_ccs.*', - 'change', - 'draftCommentThreads', - '_includeComments', - '_labelsChanged', - 'hasDrafts' - ) - _computeNewAttention( - currentUser?: AccountInfo, - reviewers?: PolymerDeepPropertyChange< - AccountInfoInput[], - AccountInfoInput[] - >, - ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>, - change?: ChangeInfo, - draftCommentThreads?: CommentThread[], - includeComments?: boolean, - _labelsChanged?: boolean, - hasDrafts?: boolean - ) { + computeNewAttention() { if ( - currentUser === undefined || - currentUser._account_id === undefined || - reviewers === undefined || - ccs === undefined || - change === undefined || - draftCommentThreads === undefined || - includeComments === undefined + this.account?._account_id === undefined || + this.change === undefined || + this.includeComments === undefined || + this.draftCommentThreads === undefined ) { return; } // The draft comments are only relevant for the attention set as long as the // user actually plans to publish their drafts. - draftCommentThreads = includeComments ? draftCommentThreads : []; - const hasVote = !!_labelsChanged; - const isOwner = this._isOwner(currentUser, change); - const isUploader = this._uploader?._account_id === currentUser._account_id; - this._attentionCcsCount = removeServiceUsers(ccs.base).length; - this._currentAttentionSet = new Set( - Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId) + const draftCommentThreads = this.includeComments + ? this.draftCommentThreads + : []; + const hasVote = !!this.labelsChanged; + const isOwner = this.isOwner(this.account, this.change); + 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); - if (change.status === ChangeStatus.NEW) { + const newAttention = new Set(this.currentAttentionSet); + if (this.change.status === ChangeStatus.NEW) { // Add everyone that the user is replying to in a comment thread. - this._computeCommentAccounts(draftCommentThreads).forEach(id => + this.computeCommentAccounts(draftCommentThreads).forEach(id => newAttention.add(id) ); // Remove the current user. - newAttention.delete(currentUser._account_id); + 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 === currentUser._account_id && (hasDrafts || hasVote)); - reviewers.base - .filter(r => r._account_id) + !( + r._account_id === this.account!._account_id && + (this.hasDrafts() || hasVote) + ); + this.reviewers + .filter(r => isAccount(r)) .filter(r => r._pendingAdd || (this.canBeStarted && isOwner)) .filter(notIsReviewerAndHasDraftOrLabel) - .forEach(r => newAttention.add(r._account_id!)); + .forEach(r => newAttention.add((r as AccountInfo)._account_id!)); // Add owner and uploader, if someone else replies. - if (hasDrafts || hasVote) { - if (this._uploader?._account_id && !isUploader) { - newAttention.add(this._uploader._account_id); + if (this.hasDrafts() || hasVote) { + if (this.uploader?._account_id && !isUploader) { + newAttention.add(this.uploader._account_id); } - if (change.owner?._account_id && !isOwner) { - newAttention.add(change.owner._account_id); + if (this.change.owner?._account_id && !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 (change.owner && hasUnresolvedDraft) { - // A change owner must have an _account_id. - newAttention.add(change.owner._account_id!); + 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(currentUser._account_id); + 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() + const allAccountIds = this.allAccounts() .map(a => a._account_id) .filter(id => !!id); - this._newAttentionSet = new Set( + this.newAttentionSet = new Set( [...newAttention].filter(id => allAccountIds.includes(id)) ); - this._attentionExpanded = this._computeShowAttentionTip( - currentUser, - change.owner, - this._currentAttentionSet, - this._newAttentionSet + this.attentionExpanded = this.computeShowAttentionTip( + this.account, + this.change.owner, + this.currentAttentionSet, + this.newAttentionSet ); } - _computeShowAttentionTip( + computeShowAttentionTip( currentUser?: AccountInfo, owner?: AccountInfo, currentAttentionSet?: Set<AccountId>, @@ -983,7 +1695,7 @@ export class GrReplyDialog extends PolymerElement { return isOwner && addedIds.length > 2; } - _computeCommentAccounts(threads: CommentThread[]) { + computeCommentAccounts(threads: CommentThread[]) { const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW]; const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id); const accountIds = new Set<AccountId>(); @@ -991,7 +1703,7 @@ export class GrReplyDialog extends PolymerElement { const unresolved = isUnresolved(thread); thread.comments.forEach(comment => { if (comment.author) { - // A comment author must have an _account_id. + // A comment author must have an account_id. const authorId = comment.author._account_id!; const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId); if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId); @@ -1001,110 +1713,93 @@ export class GrReplyDialog extends PolymerElement { return accountIds; } - _computeShowNoAttentionUpdate( - config?: ServerInfo, - currentAttentionSet?: Set<AccountId>, - newAttentionSet?: Set<AccountId>, - sendDisabled?: boolean - ) { - return ( - sendDisabled || - this._computeNewAttentionAccounts( - config, - currentAttentionSet, - newAttentionSet - ).length === 0 - ); + computeShowNoAttentionUpdate() { + return this.sendDisabled || this.computeNewAttentionAccounts().length === 0; } - _computeDoNotUpdateMessage( - currentAttentionSet?: Set<AccountId>, - newAttentionSet?: Set<AccountId>, - sendDisabled?: boolean - ) { - if (!currentAttentionSet || !newAttentionSet) return ''; - if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) { + computeDoNotUpdateMessage() { + if (!this.currentAttentionSet || !this.newAttentionSet) return ''; + if ( + this.sendDisabled || + areSetsEqual(this.currentAttentionSet, this.newAttentionSet) + ) { return 'No changes to the attention set.'; } - if (containsAll(currentAttentionSet, newAttentionSet)) { + if (containsAll(this.currentAttentionSet, this.newAttentionSet)) { return 'No additions to the attention set.'; } this.reporting.error( new Error( - '_computeDoNotUpdateMessage()' + + 'computeDoNotUpdateMessage()' + 'should not be called when users were added to the attention set.' ) ); return ''; } - _computeNewAttentionAccounts( - _?: ServerInfo, - currentAttentionSet?: Set<AccountId>, - newAttentionSet?: Set<AccountId> - ) { - if (currentAttentionSet === undefined || newAttentionSet === undefined) { + computeNewAttentionAccounts(): AccountInfo[] { + if ( + this.currentAttentionSet === undefined || + this.newAttentionSet === undefined + ) { return []; } - return [...newAttentionSet] - .filter(id => !currentAttentionSet.has(id)) - .map(id => this._findAccountById(id)) - .filter(account => !!account); + return [...this.newAttentionSet] + .filter(id => !this.currentAttentionSet.has(id)) + .map(id => this.findAccountById(id)) + .filter(account => !!account) as AccountInfo[]; } - _findAccountById(accountId: AccountId) { - return this._allAccounts().find(r => r._account_id === accountId); + findAccountById(accountId: AccountId) { + return this.allAccounts().find(r => r._account_id === accountId); } - _allAccounts() { + 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]; + 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)); } - /** - * The newAttentionSet param is only used to force re-computation. - */ - _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) { + removeServiceUsers(accounts: AccountInfo[]) { return removeServiceUsers(accounts); } - _computeUploader(change: ChangeInfo) { + computeUploader() { if ( - !change || - !change.current_revision || - !change.revisions || - !change.revisions[change.current_revision] + !this.change?.current_revision || + !this.change?.revisions?.[this.change.current_revision] ) { - return undefined; + this.uploader = undefined; + return; } - const rev = change.revisions[change.current_revision]; + const rev = this.change.revisions[this.change.current_revision]; if ( !rev.uploader || - change.owner._account_id === rev.uploader._account_id + this.change?.owner._account_id === rev.uploader._account_id ) { - return undefined; + this.uploader = undefined; + return; } - return rev.uploader; + 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. + * truthy, the function filters out entries that already exist in this.ccs. + * When falsy, the function filters entries that exist in this.reviewers. */ - _filterReviewerSuggestionGenerator( + filterReviewerSuggestionGenerator( isCCs: boolean ): (input: Suggestion) => boolean { return suggestion => { let entry: AccountInfo | GroupInfo; if (isReviewerAccountSuggestion(suggestion)) { entry = suggestion.account; - if (entry._account_id === this._owner?._account_id) { + if (entry._account_id === this.owner?._account_id) { return false; } } else if (isReviewerGroupSuggestion(suggestion)) { @@ -1120,24 +1815,20 @@ export class GrReplyDialog extends PolymerElement { const finder = (entry: AccountInfo | GroupInfo) => accountOrGroupKey(entry) === key; if (isCCs) { - return this._ccs.find(finder) === undefined; + return this.ccs.find(finder) === undefined; } - return this._reviewers.find(finder) === undefined; + return this.reviewers.find(finder) === undefined; }; } - _getAccount() { - return this.restApiService.getAccount(); - } - - _cancelTapHandler(e: Event) { + cancelTapHandler(e: Event) { e.preventDefault(); this.cancel(); } cancel() { assertIsDefined(this.change, 'change'); - if (!this._owner) throw new Error('missing required _owner property'); + if (!this.owner) throw new Error('missing required owner property'); this.dispatchEvent( new CustomEvent('cancel', { composed: true, @@ -1145,36 +1836,36 @@ export class GrReplyDialog extends PolymerElement { }) ); queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown(); - this.$.reviewers.clearPendingRemovals(); - this._rebuildReviewerArrays(this.change.reviewers, this._owner); + this.reviewersList?.clearPendingRemovals(); + this.rebuildReviewerArrays(); } - _saveClickHandler(e: Event) { + saveClickHandler(e: Event) { e.preventDefault(); - if (!this.$.ccs.submitEntryText()) { + if (!this.ccsList?.submitEntryText()) { // Do not proceed with the save if there is an invalid email entry in // the text field of the CC entry. return; } - this.send(this._includeComments, false); + this.send(this.includeComments, false); } - _sendTapHandler(e: Event) { + sendTapHandler(e: Event) { e.preventDefault(); - this._submit(); + this.submit(); } - _submit() { - if (!this.$.ccs.submitEntryText()) { + submit() { + 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._sendDisabled) { + if (this.sendDisabled) { fireAlert(this, EMPTY_REPLY_MESSAGE); return; } - return this.send(this._includeComments, this.canBeStarted).catch(err => { + return this.send(this.includeComments, this.canBeStarted).catch(err => { this.dispatchEvent( new CustomEvent('show-error', { bubbles: true, @@ -1185,7 +1876,7 @@ export class GrReplyDialog extends PolymerElement { }); } - _saveReview(review: ReviewInput, errFn?: ErrorCallback) { + saveReview(review: ReviewInput, errFn?: ErrorCallback) { assertIsDefined(this.change, 'change'); assertIsDefined(this.patchNum, 'patchNum'); return this.restApiService.saveChangeReview( @@ -1196,43 +1887,43 @@ export class GrReplyDialog extends PolymerElement { ); } - _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) { + pendingConfirmationUpdated(reviewer: RawAccountInput | null) { if (reviewer === null) { - this.$.reviewerConfirmationOverlay.close(); + this.reviewerConfirmationOverlay?.close(); } else { - this._pendingConfirmationDetails = - this._ccPendingConfirmation || this._reviewerPendingConfirmation; - this.$.reviewerConfirmationOverlay.open(); + this.pendingConfirmationDetails = + this.ccPendingConfirmation || this.reviewerPendingConfirmation; + this.reviewerConfirmationOverlay?.open(); } } - _confirmPendingReviewer() { - if (this._ccPendingConfirmation) { - this.$.ccs.confirmGroup(this._ccPendingConfirmation.group); - this._focusOn(FocusTarget.CCS); + confirmPendingReviewer() { + if (this.ccPendingConfirmation) { + this.ccsList?.confirmGroup(this.ccPendingConfirmation.group); + this.focusOn(FocusTarget.CCS); return; } - if (this._reviewerPendingConfirmation) { - this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group); - this._focusOn(FocusTarget.REVIEWERS); + if (this.reviewerPendingConfirmation) { + this.reviewersList?.confirmGroup(this.reviewerPendingConfirmation.group); + this.focusOn(FocusTarget.REVIEWERS); return; } this.reporting.error( - new Error('_confirmPendingReviewer called without pending confirm') + new Error('confirmPendingReviewer called without pending confirm') ); } - _cancelPendingReviewer() { - this._ccPendingConfirmation = null; - this._reviewerPendingConfirmation = null; + cancelPendingReviewer() { + this.ccPendingConfirmation = null; + this.reviewerPendingConfirmation = null; - const target = this._ccPendingConfirmation + const target = this.ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS; - this._focusOn(target); + this.focusOn(target); } - _getStorageLocation(): StorageLocation { + getStorageLocation(): StorageLocation { assertIsDefined(this.change, 'change'); return { changeNum: this.change._number, @@ -1241,50 +1932,73 @@ export class GrReplyDialog extends PolymerElement { }; } - _loadStoredDraft() { - const draft = this.storage.getDraftComment(this._getStorageLocation()); + loadStoredDraft() { + const draft = this.storage.getDraftComment(this.getStorageLocation()); return draft?.message ?? ''; } - _handleAccountTextEntry() { + 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; + this.reviewersMutated = true; } - _draftChanged(newDraft: string, oldDraft?: string) { + draftChanged(oldDraft: string) { this.storeTask = debounce( this.storeTask, () => { - if (!newDraft.length && oldDraft) { + if (!this.draft.length && oldDraft) { // If the draft has been modified to be empty, then erase the storage // entry. - this.storage.eraseDraftComment(this._getStorageLocation()); - } else if (newDraft.length) { - this.storage.setDraftComment(this._getStorageLocation(), this.draft); + this.storage.eraseDraftComment(this.getStorageLocation()); + } else if (this.draft.length) { + this.storage.setDraftComment(this.getStorageLocation(), this.draft); } }, STORAGE_DEBOUNCE_INTERVAL_MS ); } - _handleHeightChanged() { + handleHeightChanged() { fireEvent(this, 'autogrow'); } - getLabelScores() { - return this.$.labelScores || queryAndAssert(this, 'gr-label-scores'); + getLabelScores(): GrLabelScores { + return this.labelScores || queryAndAssert(this, 'gr-label-scores'); } _handleLabelsChanged() { - this._labelsChanged = + this.labelsChanged = Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0; } - _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) { - return knownLatestState === value; + // To decouple account-list and reply dialog + getAccountListCopy(list: (AccountInfo | GroupInfo)[]) { + return list.slice(); + } + + handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) { + this.reviewers = e.detail.value.slice(); + this.reviewersMutated = true; + } + + handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) { + this.ccs = e.detail.value.slice(); + this.reviewersMutated = true; + } + + handleReviewersConfirmationChanged( + e: ValueChangedEvent<SuggestedReviewerGroupInfo | null> + ) { + this.reviewerPendingConfirmation = e.detail.value; + } + + handleCcsConfirmationChanged( + e: ValueChangedEvent<SuggestedReviewerGroupInfo | null> + ) { + this.ccPendingConfirmation = e.detail.value; } _reload() { @@ -1292,82 +2006,77 @@ export class GrReplyDialog extends PolymerElement { this.cancel(); } - _computeSendButtonLabel(canBeStarted: boolean) { - return canBeStarted + computeSendButtonLabel() { + this.sendButtonLabel = this.canBeStarted ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW : ButtonLabels.SEND; } - _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) { + computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) { if (commentEditing) { return ButtonTooltips.DISABLED_COMMENT_EDITING; } return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND; } - _computeSavingLabelClass(savingComments: boolean) { + computeSavingLabelClass(savingComments: boolean) { return savingComments ? 'saving' : ''; } - _computeSendButtonDisabled( - canBeStarted?: boolean, - draftCommentThreads?: CommentThread[], - text?: string, - reviewersMutated?: boolean, - labelsChanged?: boolean, - includeComments?: boolean, - disabled?: boolean, - commentEditing?: boolean, - change?: ChangeInfo, - account?: AccountInfo - ) { + computeSendButtonDisabled() { if ( - canBeStarted === undefined || - draftCommentThreads === undefined || - text === undefined || - reviewersMutated === undefined || - labelsChanged === undefined || - includeComments === undefined || - disabled === undefined || - commentEditing === undefined || - change?.labels === undefined || - account === undefined + this.canBeStarted === undefined || + this.draftCommentThreads === undefined || + this.draft === 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 (commentEditing || disabled) { + if (this.commentEditing || this.disabled) { return true; } - if (canBeStarted === true) { + if (this.canBeStarted === true) { return false; } - const existingVote = Object.values(change.labels).some( - label => isDetailedLabelInfo(label) && getApprovalInfo(label, account) + const existingVote = Object.values(this.change.labels).some( + label => + isDetailedLabelInfo(label) && getApprovalInfo(label, this.account!) ); - const revotingOrNewVote = labelsChanged || existingVote; - const hasDrafts = includeComments && draftCommentThreads.length; + const revotingOrNewVote = this.labelsChanged || existingVote; + const hasDrafts = + this.includeComments && this.draftCommentThreads.length > 0; return ( - !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote + !hasDrafts && + !this.draft.length && + !this.reviewersMutated && + !revotingOrNewVote ); } - _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) { - let str = `Patch ${patchNum} is not latest.`; - if (labelsChanged) { + computePatchSetWarning() { + let str = `Patch ${this.patchNum} is not latest.`; + if (this.labelsChanged) { str += ' Voting may have no effect.'; } return str; } setPluginMessage(message: string) { - this._pluginMessage = message; + this.pluginMessage = message; } - _sendDisabledChanged() { + sendDisabledChanged() { this.dispatchEvent(new CustomEvent('send-disabled-changed')); } - _getReviewerSuggestionsProvider(change: ChangeInfo) { + getReviewerSuggestionsProvider(change?: ChangeInfo) { + if (!change) return; const provider = GrReviewerSuggestionsProvider.create( this.restApiService, change._number, @@ -1377,7 +2086,8 @@ export class GrReplyDialog extends PolymerElement { return provider; } - _getCcSuggestionsProvider(change: ChangeInfo) { + getCcSuggestionsProvider(change?: ChangeInfo) { + if (!change) return; const provider = GrReviewerSuggestionsProvider.create( this.restApiService, change._number, @@ -1395,24 +2105,24 @@ export class GrReplyDialog extends PolymerElement { 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; + 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'; + 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'; + const role = removedId === ownerId ? 'OWNER' : '_REVIEWER'; actions.push('REMOVE' + self + role); } this.reporting.reportInteraction('attention-set-actions', {actions}); } - _computeAllReviewers() { - return [...this._reviewers]; + computeAllReviewers() { + this.allReviewers = [...this.reviewers]; } } |