| /** |
| * @license |
| * Copyright 2016 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../gr-account-chip/gr-account-chip'; |
| import '../gr-account-entry/gr-account-entry'; |
| import {getAppContext} from '../../../services/app-context'; |
| import { |
| ChangeInfo, |
| Suggestion, |
| AccountInfo, |
| GroupInfo, |
| EmailAddress, |
| SuggestedReviewerGroupInfo, |
| SuggestedReviewerInfo, |
| isGroup, |
| } from '../../../types/common'; |
| import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider'; |
| import {GrAccountEntry} from '../gr-account-entry/gr-account-entry'; |
| import {GrAccountChip} from '../gr-account-chip/gr-account-chip'; |
| import {fire, fireAlert} from '../../../utils/event-util'; |
| import { |
| AccountInput, |
| getUserId, |
| isAccountNewlyAdded, |
| isAccountObject, |
| isSuggestedReviewerGroupInfo, |
| RawAccountInput, |
| } from '../../../utils/account-util'; |
| import {LitElement, css, html, PropertyValues} from 'lit'; |
| import {customElement, property, query, state} from 'lit/decorators.js'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {classMap} from 'lit/directives/class-map.js'; |
| import { |
| AutocompleteQuery, |
| AutocompleteSuggestion, |
| GrAutocomplete, |
| } from '../gr-autocomplete/gr-autocomplete'; |
| import {ValueChangedEvent} from '../../../types/events'; |
| import {difference, queryAndAssert} from '../../../utils/common-util'; |
| import {PaperInputElement} from '@polymer/paper-input/paper-input'; |
| import {IronInputElement} from '@polymer/iron-input'; |
| import {ReviewerState} from '../../../api/rest-api'; |
| import {repeat} from 'lit/directives/repeat.js'; |
| |
| const VALID_EMAIL_ALERT = 'Please input a valid email.'; |
| const VALID_USER_GROUP_ALERT = 'Please input a valid user or group.'; |
| |
| declare global { |
| interface HTMLElementEventMap { |
| 'accounts-changed': ValueChangedEvent<(AccountInfo | GroupInfo)[]>; |
| 'pending-confirmation-changed': ValueChangedEvent<SuggestedReviewerGroupInfo | null>; |
| 'account-added': CustomEvent<{account: AccountInfo | GroupInfo}>; |
| } |
| interface HTMLElementTagNameMap { |
| 'gr-account-list': GrAccountList; |
| } |
| } |
| |
| @customElement('gr-account-list') |
| export class GrAccountList extends LitElement { |
| /** |
| * Fired when user inputs an invalid email address. |
| * |
| * @event show-alert |
| */ |
| @query('#entry') entry?: GrAccountEntry; |
| |
| @property({type: Array}) |
| accounts: AccountInput[] = []; |
| |
| @property({type: Object}) |
| change?: ChangeInfo; |
| |
| @property({type: Object}) |
| filter?: (input: Suggestion) => boolean; |
| |
| @property() |
| placeholder = ''; |
| |
| @property({type: Boolean}) |
| disabled = false; |
| |
| @property({type: String}) |
| reviewerState?: ReviewerState; |
| |
| /** |
| * Returns suggestions and convert them to list item |
| */ |
| @property({type: Object}) |
| suggestionsProvider?: ReviewerSuggestionsProvider; |
| |
| /** |
| * Needed for template checking since value is initially set to null. |
| */ |
| @property({type: Object}) |
| pendingConfirmation: SuggestedReviewerGroupInfo | null = null; |
| |
| @property({type: Boolean}) |
| readonly = false; |
| |
| /** |
| * When true, allows for non-suggested inputs to be added. |
| */ |
| @property({type: Boolean, attribute: 'allow-any-input'}) |
| allowAnyInput = false; |
| |
| /** |
| * Array of values (groups/accounts) that are removable. When this prop is |
| * undefined, all values are removable. |
| */ |
| @property({type: Array}) |
| removableValues?: AccountInput[]; |
| |
| /** |
| * Returns suggestion items |
| */ |
| @state() private querySuggestions: AutocompleteQuery<SuggestedReviewerInfo>; |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| constructor() { |
| super(); |
| this.querySuggestions = input => this.getSuggestions(input); |
| this.addEventListener('remove-account', e => |
| this.handleRemove(e as CustomEvent<{account: AccountInput}>) |
| ); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| gr-account-chip { |
| display: inline-block; |
| margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0; |
| } |
| gr-account-entry { |
| display: flex; |
| flex: 1; |
| min-width: 10em; |
| margin: var(--spacing-xs) var(--spacing-xs) var(--spacing-xs) 0; |
| } |
| .group { |
| --account-label-suffix: ' (group)'; |
| } |
| .newlyAdded { |
| font-style: italic; |
| } |
| .list { |
| align-items: center; |
| display: flex; |
| flex-wrap: wrap; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| return html`<div class="list"> |
| ${repeat( |
| this.accounts, |
| account => getUserId(account), |
| account => html` |
| <gr-account-chip |
| .account=${account} |
| .change=${this.change} |
| class=${classMap({ |
| group: isGroup(account), |
| newlyAdded: isAccountNewlyAdded( |
| account, |
| this.reviewerState, |
| this.change |
| ), |
| })} |
| ?removable=${this.computeRemovable(account)} |
| @keydown=${this.handleChipKeydown} |
| tabindex="-1" |
| > |
| </gr-account-chip> |
| ` |
| )} |
| </div> |
| <gr-account-entry |
| borderless="" |
| ?hidden=${this.readonly} |
| id="entry" |
| .placeholder=${this.placeholder} |
| @add=${this.handleAdd} |
| @keydown=${this.handleInputKeydown} |
| .allowAnyInput=${this.allowAnyInput} |
| .querySuggestions=${this.querySuggestions} |
| > |
| </gr-account-entry> |
| <slot></slot>`; |
| } |
| |
| override willUpdate(changedProperties: PropertyValues) { |
| if (changedProperties.has('pendingConfirmation')) { |
| fire(this, 'pending-confirmation-changed', { |
| value: this.pendingConfirmation, |
| }); |
| } |
| } |
| |
| get accountChips(): GrAccountChip[] { |
| return Array.from( |
| this.shadowRoot?.querySelectorAll('gr-account-chip') || [] |
| ); |
| } |
| |
| get focusStart() { |
| // Entry is always defined and we cannot return undefined. |
| return this.entry?.focusStart; |
| } |
| |
| getSuggestions( |
| input: string |
| ): Promise<AutocompleteSuggestion<SuggestedReviewerInfo>[]> { |
| const provider = this.suggestionsProvider; |
| if (!provider) return Promise.resolve([]); |
| return provider.getSuggestions(input).then(suggestions => { |
| if (!suggestions) return []; |
| if (this.filter) { |
| suggestions = suggestions.filter(this.filter); |
| } |
| return suggestions.map(suggestion => |
| provider.makeSuggestionItem(suggestion) |
| ); |
| }); |
| } |
| |
| // private but used in test |
| handleAdd(e: ValueChangedEvent<string>) { |
| // TODO(TS) this is temporary hack to avoid cascade of ts issues |
| const item = e.detail.value as RawAccountInput; |
| this.addAccountItem(item); |
| } |
| |
| /** |
| * Check if account or group is valid and add it. |
| * |
| * @return true if account or group is added. |
| */ |
| addAccountItem(item: RawAccountInput): boolean { |
| // Append new account or group to the accounts property. We add our own |
| // internal properties to the account/group here, so we clone the object |
| // to avoid cluttering up the shared change object. |
| let accountOrGroup: AccountInfo | GroupInfo | undefined; |
| let itemTypeAdded = 'unknown'; |
| if (isAccountObject(item)) { |
| accountOrGroup = {...item.account}; |
| this.accounts.push(accountOrGroup); |
| itemTypeAdded = 'account'; |
| } else if (isSuggestedReviewerGroupInfo(item)) { |
| if (item.confirm) { |
| this.pendingConfirmation = item; |
| return false; |
| } |
| accountOrGroup = {...item.group}; |
| this.accounts.push(accountOrGroup); |
| itemTypeAdded = 'group'; |
| } else if (this.allowAnyInput) { |
| if (!item.includes('@')) { |
| // Repopulate the input with what the user tried to enter and have |
| // a toast tell them why they can't enter it. |
| this.entry?.setText(item); |
| fireAlert(this, VALID_EMAIL_ALERT); |
| return false; |
| } else { |
| accountOrGroup = {email: item as EmailAddress}; |
| this.accounts.push(accountOrGroup); |
| itemTypeAdded = 'email'; |
| } |
| } |
| if (!accountOrGroup) { |
| fireAlert(this, VALID_USER_GROUP_ALERT); |
| return false; |
| } |
| fire(this, 'accounts-changed', {value: this.accounts.slice()}); |
| fire(this, 'account-added', {account: accountOrGroup}); |
| this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded}); |
| this.pendingConfirmation = null; |
| this.requestUpdate(); |
| return true; |
| } |
| |
| confirmGroup(group: GroupInfo) { |
| this.accounts.push({ |
| ...group, |
| confirmed: true, |
| }); |
| this.pendingConfirmation = null; |
| fire(this, 'accounts-changed', {value: this.accounts}); |
| this.requestUpdate(); |
| } |
| |
| // private but used in test |
| computeRemovable(account: AccountInput) { |
| if (this.readonly) { |
| return false; |
| } |
| if (this.removableValues) { |
| for (let i = 0; i < this.removableValues.length; i++) { |
| if (getUserId(this.removableValues[i]) === getUserId(account)) { |
| return true; |
| } |
| } |
| return isAccountNewlyAdded(account, this.reviewerState, this.change); |
| } |
| return true; |
| } |
| |
| private handleRemove(e: CustomEvent<{account: AccountInput}>) { |
| const toRemove = e.detail.account; |
| this.removeAccount(toRemove); |
| this.entry?.focus(); |
| } |
| |
| removeAccount(toRemove?: AccountInput) { |
| if (!toRemove || !this.computeRemovable(toRemove)) { |
| return false; |
| } |
| for (let i = 0; i < this.accounts.length; i++) { |
| if (getUserId(toRemove) === getUserId(this.accounts[i])) { |
| this.accounts.splice(i, 1); |
| this.reporting.reportInteraction(`Remove from ${this.id}`); |
| this.requestUpdate(); |
| fire(this, 'accounts-changed', {value: this.accounts.slice()}); |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| // private but used in test |
| getOwnNativeInput(paperInput: PaperInputElement) { |
| return (paperInput.inputElement as IronInputElement) |
| .inputElement as HTMLInputElement; |
| } |
| |
| private handleInputKeydown(e: KeyboardEvent) { |
| const target = e.target as GrAccountEntry; |
| const entryInput = queryAndAssert<GrAutocomplete>(target, '#input'); |
| const input = this.getOwnNativeInput(entryInput.input!); |
| if ( |
| input.selectionStart !== input.selectionEnd || |
| input.selectionStart !== 0 |
| ) { |
| return; |
| } |
| switch (e.key) { |
| case 'Backspace': |
| this.removeAccount(this.accounts[this.accounts.length - 1]); |
| break; |
| case 'ArrowLeft': |
| if (this.accountChips[this.accountChips.length - 1]) { |
| this.accountChips[this.accountChips.length - 1].focus(); |
| } |
| break; |
| } |
| } |
| |
| private handleChipKeydown(e: KeyboardEvent) { |
| const chip = e.target as GrAccountChip; |
| const chips = this.accountChips; |
| const index = chips.indexOf(chip); |
| switch (e.key) { |
| case 'Backspace': |
| case 'Enter': |
| case ' ': |
| case 'Delete': |
| this.removeAccount(chip.account); |
| // Splice from this array to avoid inconsistent ordering of |
| // event handling. |
| chips.splice(index, 1); |
| if (index < chips.length) { |
| chips[index].focus(); |
| } else if (index > 0) { |
| chips[index - 1].focus(); |
| } else { |
| this.entry?.focus(); |
| } |
| break; |
| case 'ArrowLeft': |
| if (index > 0) { |
| chip.blur(); |
| chips[index - 1].focus(); |
| } |
| break; |
| case 'ArrowRight': |
| chip.blur(); |
| if (index < chips.length - 1) { |
| chips[index + 1].focus(); |
| } else { |
| this.entry?.focus(); |
| } |
| break; |
| } |
| } |
| |
| /** |
| * Submit the text of the entry as a reviewer value, if it exists. If it is |
| * a successful submit of the text, clear the entry value. |
| * |
| * @return If there is text in the entry, return true if the |
| * submission was successful and false if not. If there is no text, |
| * return true. |
| */ |
| submitEntryText() { |
| const text = this.entry?.getText(); |
| if (!text?.length) { |
| return true; |
| } |
| const wasSubmitted = this.addAccountItem(text); |
| if (wasSubmitted) { |
| this.entry?.clear(); |
| } |
| return wasSubmitted; |
| } |
| |
| additions(): AccountInput[] { |
| if (!this.change) return []; |
| return this.accounts.filter(account => |
| isAccountNewlyAdded(account, this.reviewerState, this.change) |
| ); |
| } |
| |
| removals(): AccountInfo[] { |
| if (!this.reviewerState) return []; |
| return difference( |
| this.change?.reviewers[this.reviewerState] ?? [], |
| this.accounts, |
| (a, b) => getUserId(a) === getUserId(b) |
| ); |
| } |
| } |