blob: 08d32188710416ba4e57c1e2b35368b05295a411 [file] [log] [blame]
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {css, html, LitElement, nothing} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {resolve} from '../../../models/dependency';
import {ChangeInfo, TopicName} from '../../../types/common';
import {subscribe} from '../../lit/subscription-controller';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '@polymer/iron-dropdown/iron-dropdown';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
import {getAppContext} from '../../../services/app-context';
import {isDefined} from '../../../types/types';
import {unique} from '../../../utils/common-util';
import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
import {when} from 'lit/directives/when.js';
import {ValueChangedEvent} from '../../../types/events';
import {classMap} from 'lit/directives/class-map.js';
import {spinnerStyles} from '../../../styles/gr-spinner-styles';
import {ProgressStatus} from '../../../constants/constants';
import {allSettled} from '../../../utils/async-util';
import {fireReload} from '../../../utils/event-util';
import {fireAlert} from '../../../utils/event-util';
import {pluralize} from '../../../utils/string-util';
import {Interaction} from '../../../constants/reporting';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@customElement('gr-change-list-topic-flow')
export class GrChangeListTopicFlow extends LitElement {
@state() private selectedChanges: ChangeInfo[] = [];
@state() private topicToAdd: TopicName = '' as TopicName;
@state() private existingTopicSuggestions: TopicName[] = [];
@state() private loadingText?: string;
@state() private errorText?: string;
/** dropdown status is tracked here to lazy-load the inner DOM contents */
@state() private isDropdownOpen = false;
@state() private overallProgress: ProgressStatus = ProgressStatus.NOT_STARTED;
@query('iron-dropdown') private dropdown?: IronDropdownElement;
private selectedExistingTopics: Set<TopicName> = new Set();
private getBulkActionsModel = resolve(this, bulkActionsModelToken);
private restApiService = getAppContext().restApiService;
private readonly reportingService = getAppContext().reportingService;
static override get styles() {
return [
spinnerStyles,
css`
iron-dropdown {
box-shadow: var(--elevation-level-2);
width: 400px;
background-color: var(--dialog-background-color);
border-radius: 4px;
}
[slot='dropdown-content'] {
padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l);
}
gr-autocomplete {
--prominent-border-color: var(--gray-800);
}
.footer {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.buttons {
padding-top: var(--spacing-m);
display: flex;
justify-content: flex-end;
gap: var(--spacing-m);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: var(--spacing-s) var(--spacing-xl);
border-radius: 10px;
width: fit-content;
cursor: pointer;
color: var(--primary-text-color);
}
.chip:not(.selected) {
border: var(--spacing-xxs) solid var(--border-color);
background: none;
}
.chip.selected {
border: 0;
color: var(--selected-foreground);
background-color: var(--selected-chip-background);
margin: var(--spacing-xxs);
}
.loadingOrError {
display: flex;
align-items: baseline;
gap: var(--spacing-s);
}
/* The basics of .loadingSpin are defined in spinnerStyles. */
.loadingSpin {
vertical-align: top;
position: relative;
top: 3px;
}
.error {
color: var(--deemphasized-text-color);
}
gr-icon {
color: var(--error-color);
/* Center with text by aligning it to the top and then pushing it down
to match the text */
vertical-align: top;
position: relative;
top: 7px;
}
`,
];
}
constructor() {
super();
subscribe(
this,
() => this.getBulkActionsModel().selectedChanges$,
selectedChanges => {
this.selectedChanges = selectedChanges;
}
);
}
override render() {
const isFlowDisabled = this.selectedChanges.length === 0;
return html`
<gr-button
id="start-flow"
flatten
down-arrow
@click=${this.toggleDropdown}
.disabled=${isFlowDisabled}
>Topic</gr-button
>
<iron-dropdown
.horizontalAlign=${'auto'}
.verticalAlign=${'auto'}
.verticalOffset=${24}
@opened-changed=${(e: ValueChangedEvent<boolean>) =>
(this.isDropdownOpen = e.detail.value)}
>
${when(
this.isDropdownOpen,
() => html`
<div slot="dropdown-content">
${when(
this.selectedChanges.some(change => change.topic),
() => this.renderExistingTopicsMode(),
() => this.renderNoExistingTopicsMode()
)}
</div>
`
)}
</iron-dropdown>
`;
}
private disableApplyToAllButton() {
if (this.selectedExistingTopics.size !== 1) return true;
// Ensure there is one selected change that does not have this topic
// already
return !this.selectedChanges
.map(change => change.topic)
.filter(unique)
.some(topic => !topic || !this.selectedExistingTopics.has(topic));
}
private renderExistingTopicsMode() {
const topics = this.selectedChanges
.map(change => change.topic)
.filter(isDefined)
.filter(unique);
const removeDisabled =
this.selectedExistingTopics.size === 0 ||
this.overallProgress === ProgressStatus.RUNNING;
return html`
<div class="chips">
${topics.map(name => this.renderExistingTopicChip(name))}
</div>
<div class="footer">
<div class="loadingOrError" role="progressbar">
${this.renderLoadingOrError()}
</div>
<div class="buttons">
${when(
this.overallProgress !== ProgressStatus.FAILED,
() => html` <gr-button
id="apply-to-all-button"
flatten
?disabled=${this.disableApplyToAllButton()}
@click=${this.applyTopicToAll}
>Apply${this.selectedChanges.length > 1
? ' to all'
: nothing}</gr-button
>
<gr-button
id="remove-topics-button"
flatten
?disabled=${removeDisabled}
@click=${this.removeTopics}
>Remove</gr-button
>`,
() =>
html`
<gr-button
id="cancel-button"
flatten
@click=${this.closeDropdown}
>Cancel</gr-button
>
`
)}
</div>
</div>
`;
}
private renderExistingTopicChip(name: TopicName) {
const chipClasses = {
chip: true,
selected: this.selectedExistingTopics.has(name),
};
return html`
<button
role="listbox"
aria-label=${`${name as string} selection`}
class=${classMap(chipClasses)}
@click=${() => this.toggleExistingTopicSelected(name)}
>
${name}
</button>
`;
}
private renderLoadingOrError() {
switch (this.overallProgress) {
case ProgressStatus.RUNNING:
return html`
<span class="loadingSpin"></span>
<span class="loadingText">${this.loadingText}</span>
`;
case ProgressStatus.FAILED:
return html`
<gr-icon icon="error" filled></gr-icon>
<div class="error">${this.errorText}</div>
`;
default:
return nothing;
}
}
private renderNoExistingTopicsMode() {
const isApplyTopicDisabled =
this.topicToAdd === '' || this.overallProgress === ProgressStatus.RUNNING;
return html`
<!--
The .query function needs to be bound to this because lit's autobind
seems to work only for @event handlers.
'this.getTopicSuggestions.bind(this)' gets in trouble with our linter
even though the bind is necessary here, so an anonymous function is used
instead.
-->
<gr-autocomplete
.text=${this.topicToAdd}
.query=${(query: string) => this.getTopicSuggestions(query)}
show-blue-focus-border
placeholder="Type topic name to create or filter topics"
@text-changed=${(e: ValueChangedEvent<TopicName>) =>
(this.topicToAdd = e.detail.value)}
></gr-autocomplete>
<div class="footer">
<div class="loadingOrError" role="progressbar">
${this.renderLoadingOrError()}
</div>
<div class="buttons">
${when(
this.overallProgress !== ProgressStatus.FAILED,
() => html`
<gr-button
id="set-topic-button"
flatten
@click=${() => this.setTopic('Setting topic...')}
.disabled=${isApplyTopicDisabled}
>Set Topic</gr-button
>
`,
() => html`
<gr-button id="cancel-button" flatten @click=${this.closeDropdown}
>Cancel</gr-button
>
`
)}
</div>
</div>
`;
}
private toggleDropdown() {
this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
}
private reset() {
this.topicToAdd = '' as TopicName;
this.selectedExistingTopics = new Set();
this.overallProgress = ProgressStatus.NOT_STARTED;
this.errorText = undefined;
}
private closeDropdown() {
this.isDropdownOpen = false;
this.dropdown?.close();
}
private openDropdown() {
this.reset();
this.isDropdownOpen = true;
this.dropdown?.open();
}
private async getTopicSuggestions(
query: string
): Promise<AutocompleteSuggestion[]> {
const suggestions = await this.restApiService.getChangesWithSimilarTopic(
query,
throwingErrorCallback
);
this.existingTopicSuggestions = (suggestions ?? [])
.map(change => change.topic)
.filter(isDefined)
.filter(unique);
return this.existingTopicSuggestions.map(topic => {
return {name: topic, value: topic};
});
}
private removeTopics() {
this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
type: 'removing-topic',
selectedChangeCount: this.selectedChanges.length,
});
this.loadingText = `Removing topic${
this.selectedExistingTopics.size > 1 ? 's' : ''
}...`;
this.trackPromises(
this.selectedChanges
.filter(
change =>
change.topic && this.selectedExistingTopics.has(change.topic)
)
.map(
change =>
// With throwing callback guaranteed to be non-null.
this.restApiService.removeChangeTopic(
change._number,
throwingErrorCallback
) as Promise<string>
),
`${this.selectedChanges[0].topic} removed from changes`,
'Failed to remove topic'
);
}
private applyTopicToAll() {
this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
type: 'apply-topic-to-all',
selectedChangeCount: this.selectedChanges.length,
});
this.loadingText = 'Applying to all';
const topic = Array.from(this.selectedExistingTopics.values())[0];
this.trackPromises(
this.selectedChanges.map(
change =>
// With throwing callback guaranteed to be non-null.
this.restApiService.setChangeTopic(
change._number,
topic,
throwingErrorCallback
) as Promise<string>
),
`${topic} applied to all changes`,
'Failed to apply topic'
);
}
private setTopic(loadingText: string) {
this.reportingService.reportInteraction(Interaction.BULK_ACTION, {
type: 'add-topic',
selectedChangeCount: this.selectedChanges.length,
});
const alert = `${pluralize(
this.selectedChanges.length,
'Change'
)} added to ${this.topicToAdd}`;
this.loadingText = loadingText;
this.trackPromises(
this.selectedChanges.map(
change =>
// With throwing callback guaranteed to be non-null.
this.restApiService.setChangeTopic(
change._number,
this.topicToAdd,
throwingErrorCallback
) as Promise<string>
),
alert,
'Failed to set topic'
);
}
private async trackPromises(
promises: Promise<string>[],
alert: string,
errorMessage: string
) {
this.overallProgress = ProgressStatus.RUNNING;
const results = await allSettled(promises);
if (results.every(result => result.status === 'fulfilled')) {
this.overallProgress = ProgressStatus.SUCCESSFUL;
this.closeDropdown();
if (alert) {
fireAlert(this, alert);
}
fireReload(this);
} else {
this.overallProgress = ProgressStatus.FAILED;
this.errorText = errorMessage;
}
}
private toggleExistingTopicSelected(name: TopicName) {
if (this.selectedExistingTopics.has(name)) {
this.selectedExistingTopics.delete(name);
} else {
this.selectedExistingTopics.add(name);
}
this.requestUpdate();
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-list-topic-flow': GrChangeListTopicFlow;
}
}