Introduce Hashtag bulk action flow
Currently it is identical to the topic flow. Next I will make
modifications unique to the hashtag flow.
https://imgur.com/a/5LMMFNd
Google-Bug-Id: b/220852728
Release-Notes: skip
Change-Id: I2c8358d1eb0637e515d53875d8fa6f3b2d00fbbe
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 7b14612..55fe277 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -14,6 +14,7 @@
import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
+import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
/**
* An action bar for the top of a <gr-change-list-section> element. Assumes it
@@ -111,6 +112,7 @@
<div class="actionButtons">
<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
<gr-change-list-topic-flow></gr-change-list-topic-flow>
+ <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
</div>
</div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index bc73990..df68f5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -66,6 +66,7 @@
<div class="actionButtons">
<gr-change-list-bulk-vote-flow></gr-change-list-bulk-vote-flow>
<gr-change-list-topic-flow></gr-change-list-topic-flow>
+ <gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
</div>
</div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
new file mode 100644
index 0000000..fdeb512
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -0,0 +1,379 @@
+/**
+ * @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';
+import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
+import {resolve} from '../../../models/dependency';
+import {ChangeInfo, Hashtag} from '../../../types/common';
+import {subscribe} from '../../lit/subscription-controller';
+import '../../shared/gr-button/gr-button';
+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 {notUndefined} from '../../../types/types';
+import {unique} from '../../../utils/common-util';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {when} from 'lit/directives/when';
+import {ValueChangedEvent} from '../../../types/events';
+import {classMap} from 'lit/directives/class-map';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {ProgressStatus} from '../../../constants/constants';
+import {allSettled} from '../../../utils/async-util';
+
+@customElement('gr-change-list-hashtag-flow')
+export class GrChangeListHashtagFlow extends LitElement {
+ @state() private selectedChanges: ChangeInfo[] = [];
+
+ @state() private hashtagToAdd: Hashtag = '' as Hashtag;
+
+ @state() private existingHashtagSuggestions: Hashtag[] = [];
+
+ @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 selectedExistingHashtags: Set<Hashtag> = new Set();
+
+ private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+
+ private restApiService = getAppContext().restApiService;
+
+ 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 {
+ --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;
+ }
+ .chip:not(.selected) {
+ border: var(--spacing-xxs) solid var(--gray-300);
+ }
+ .chip.selected {
+ color: var(--blue-800);
+ background-color: var(--blue-50);
+ margin: var(--spacing-xxs);
+ }
+ .loadingOrError {
+ display: flex;
+ gap: var(--spacing-s);
+ }
+
+ /* The basics of .loadingSpin are defined in spinnerStyles. */
+ .loadingSpin {
+ vertical-align: top;
+ position: relative;
+ top: 3px;
+ }
+ `,
+ ];
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ 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
+ @click=${this.toggleDropdown}
+ .disabled=${isFlowDisabled}
+ >Hashtag</gr-button
+ >
+ <iron-dropdown
+ .horizontalAlign=${'auto'}
+ .verticalAlign=${'auto'}
+ .verticalOffset=${24}
+ @opened-changed=${(e: CustomEvent) =>
+ (this.isDropdownOpen = e.detail.value)}
+ >
+ ${when(
+ this.isDropdownOpen,
+ () => html`
+ <div slot="dropdown-content">
+ ${when(
+ this.selectedChanges.some(change => change.hashtags?.length),
+ () => this.renderExistingHashtagsMode(),
+ () => this.renderNoExistingHashtagsMode()
+ )}
+ </div>
+ `
+ )}
+ </iron-dropdown>
+ `;
+ }
+
+ private renderExistingHashtagsMode() {
+ const hashtags = this.selectedChanges
+ .flatMap(change => change.hashtags ?? [])
+ .filter(notUndefined)
+ .filter(unique);
+ const removeDisabled =
+ this.selectedExistingHashtags.size === 0 ||
+ this.overallProgress === ProgressStatus.RUNNING;
+ const applyToAllDisabled = this.selectedExistingHashtags.size !== 1;
+ return html`
+ <div class="chips">
+ ${hashtags.map(name => this.renderExistingHashtagChip(name))}
+ </div>
+ <div class="footer">
+ <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+ <div class="buttons">
+ <gr-button
+ id="apply-to-all-button"
+ flatten
+ ?disabled=${applyToAllDisabled}
+ @click=${this.applyHashtagToAll}
+ >Apply to all</gr-button
+ >
+ <gr-button
+ id="remove-hashtags-button"
+ flatten
+ ?disabled=${removeDisabled}
+ @click=${this.removeHashtags}
+ >Remove</gr-button
+ >
+ </div>
+ </div>
+ `;
+ }
+
+ private renderExistingHashtagChip(name: Hashtag) {
+ const chipClasses = {
+ chip: true,
+ selected: this.selectedExistingHashtags.has(name),
+ };
+ return html`
+ <span
+ role="button"
+ aria-label=${name as string}
+ class=${classMap(chipClasses)}
+ @click=${() => this.toggleExistingHashtagSelected(name)}
+ >
+ ${name}
+ </span>
+ `;
+ }
+
+ private renderLoadingOrError() {
+ if (this.overallProgress === ProgressStatus.RUNNING) {
+ return html`
+ <span class="loadingSpin"></span>
+ <span class="loadingText">${this.loadingText}</span>
+ `;
+ } else if (this.errorText !== undefined) {
+ return html`<div class="error">${this.errorText}</div>`;
+ }
+ return nothing;
+ }
+
+ private renderNoExistingHashtagsMode() {
+ const isCreateNewHashtagDisabled =
+ this.hashtagToAdd === '' ||
+ this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+ this.overallProgress === ProgressStatus.RUNNING;
+ const isApplyHashtagDisabled =
+ this.hashtagToAdd === '' ||
+ !this.existingHashtagSuggestions.includes(this.hashtagToAdd) ||
+ 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.getHashtagSuggestions.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.hashtagToAdd}
+ .query=${(query: string) => this.getHashtagSuggestions(query)}
+ show-blue-focus-border
+ placeholder="Type hashtag name to create or filter hashtags"
+ @text-changed=${(e: ValueChangedEvent<Hashtag>) =>
+ (this.hashtagToAdd = e.detail.value)}
+ ></gr-autocomplete>
+ <div class="footer">
+ <div class="loadingOrError">${this.renderLoadingOrError()}</div>
+ <div class="buttons">
+ <gr-button
+ id="create-new-hashtag-button"
+ flatten
+ @click=${() => this.addHashtag('Creating hashtag...')}
+ .disabled=${isCreateNewHashtagDisabled}
+ >Create new hashtag</gr-button
+ >
+ <gr-button
+ id="apply-hashtag-button"
+ flatten
+ @click=${() => this.addHashtag('Applying hashtag...')}
+ .disabled=${isApplyHashtagDisabled}
+ >Apply</gr-button
+ >
+ </div>
+ </div>
+ `;
+ }
+
+ private toggleDropdown() {
+ if (this.isDropdownOpen) {
+ this.closeDropdown();
+ } else {
+ this.reset();
+ this.openDropdown();
+ }
+ }
+
+ private reset() {
+ this.hashtagToAdd = '' as Hashtag;
+ this.selectedExistingHashtags = new Set();
+ this.overallProgress = ProgressStatus.NOT_STARTED;
+ this.errorText = undefined;
+ }
+
+ private closeDropdown() {
+ this.isDropdownOpen = false;
+ this.dropdown?.close();
+ }
+
+ private openDropdown() {
+ this.isDropdownOpen = true;
+ this.dropdown?.open();
+ }
+
+ private async getHashtagSuggestions(
+ query: string
+ ): Promise<AutocompleteSuggestion[]> {
+ const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
+ query
+ );
+ this.existingHashtagSuggestions = (suggestions ?? [])
+ .flatMap(change => change.hashtags ?? [])
+ .filter(notUndefined)
+ .filter(unique);
+ return this.existingHashtagSuggestions.map(hashtag => {
+ return {name: hashtag, value: hashtag};
+ });
+ }
+
+ private removeHashtags() {
+ this.loadingText = `Removing hashtag${
+ this.selectedExistingHashtags.size > 1 ? 's' : ''
+ }...`;
+ this.trackPromises(
+ this.selectedChanges
+ .filter(
+ change =>
+ change.hashtags &&
+ change.hashtags.some(hashtag =>
+ this.selectedExistingHashtags.has(hashtag)
+ )
+ )
+ .map(change =>
+ this.restApiService.setChangeHashtag(change._number, {
+ remove: Array.from(this.selectedExistingHashtags.values()),
+ })
+ )
+ );
+ }
+
+ private applyHashtagToAll() {
+ this.loadingText = 'Applying hashtag to all';
+ this.trackPromises(
+ this.selectedChanges.map(change =>
+ this.restApiService.setChangeHashtag(change._number, {
+ add: Array.from(this.selectedExistingHashtags.values()),
+ })
+ )
+ );
+ }
+
+ private addHashtag(loadingText: string) {
+ this.loadingText = loadingText;
+ this.trackPromises(
+ this.selectedChanges.map(change =>
+ this.restApiService.setChangeHashtag(change._number, {
+ add: [this.hashtagToAdd],
+ })
+ )
+ );
+ }
+
+ private async trackPromises(promises: Promise<Hashtag[]>[]) {
+ this.overallProgress = ProgressStatus.RUNNING;
+ const results = await allSettled(promises);
+ if (results.every(result => result.status === 'fulfilled')) {
+ this.overallProgress = ProgressStatus.SUCCESSFUL;
+ this.closeDropdown();
+ // TODO: fire reload of dashboard
+ } else {
+ this.overallProgress = ProgressStatus.FAILED;
+ // TODO: when some are rejected, show error and Cancel button
+ }
+ }
+
+ private toggleExistingHashtagSelected(name: Hashtag) {
+ if (this.selectedExistingHashtags.has(name)) {
+ this.selectedExistingHashtags.delete(name);
+ } else {
+ this.selectedExistingHashtags.add(name);
+ }
+ this.requestUpdate();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-hashtag-flow': GrChangeListHashtagFlow;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
new file mode 100644
index 0000000..6910ae2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -0,0 +1,542 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {
+ BulkActionsModel,
+ bulkActionsModelToken,
+} from '../../../models/bulk-actions/bulk-actions-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {getAppContext} from '../../../services/app-context';
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {
+ MockPromise,
+ mockPromise,
+ queryAll,
+ queryAndAssert,
+ stubRestApi,
+ waitUntil,
+ waitUntilCalled,
+ waitUntilObserved,
+} from '../../../test/test-utils';
+import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import './gr-change-list-hashtag-flow';
+import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+
+suite('gr-change-list-hashtag-flow tests', () => {
+ let element: GrChangeListHashtagFlow;
+ let model: BulkActionsModel;
+
+ async function selectChange(change: ChangeInfo) {
+ model.addSelectedChangeNum(change._number);
+ await waitUntilObserved(model.selectedChanges$, selected =>
+ selected.some(other => other._number === change._number)
+ );
+ await element.updateComplete;
+ }
+
+ suite('dropdown closed', () => {
+ const changes: ChangeInfo[] = [
+ {
+ ...createChange(),
+ _number: 1 as NumericChangeId,
+ subject: 'Subject 1',
+ },
+ {
+ ...createChange(),
+ _number: 2 as NumericChangeId,
+ subject: 'Subject 2',
+ },
+ ];
+
+ setup(async () => {
+ stubRestApi('getDetailedChangesWithActions').resolves(changes);
+ model = new BulkActionsModel(getAppContext().restApiService);
+ model.sync(changes);
+
+ element = (
+ await fixture(
+ wrapInProvider(
+ html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+ bulkActionsModelToken,
+ model
+ )
+ )
+ ).querySelector('gr-change-list-hashtag-flow')!;
+ await selectChange(changes[0]);
+ await selectChange(changes[1]);
+ await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+ await element.updateComplete;
+ });
+
+ test('skips dropdown render when closed', async () => {
+ expect(element).shadowDom.to.equal(/* HTML */ `
+ <gr-button
+ id="start-flow"
+ flatten=""
+ aria-disabled="false"
+ role="button"
+ tabindex="0"
+ >Hashtag</gr-button
+ >
+ <iron-dropdown
+ aria-disabled="false"
+ aria-hidden="true"
+ style="outline: none; display: none;"
+ vertical-align="auto"
+ horizontal-align="auto"
+ >
+ </iron-dropdown>
+ `);
+ });
+
+ test('dropdown hidden before flow button clicked', async () => {
+ const dropdown = queryAndAssert<IronDropdownElement>(
+ element,
+ 'iron-dropdown'
+ );
+ assert.isFalse(dropdown.opened);
+ });
+
+ test('flow button click shows dropdown', async () => {
+ const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+
+ button.click();
+ await element.updateComplete;
+
+ const dropdown = queryAndAssert<IronDropdownElement>(
+ element,
+ 'iron-dropdown'
+ );
+ assert.isTrue(dropdown.opened);
+ });
+
+ test('flow button click when open hides dropdown', async () => {
+ queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+ await waitUntil(() =>
+ Boolean(
+ queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+ )
+ );
+ queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+ await waitUntil(
+ () =>
+ !queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened
+ );
+ });
+ });
+
+ suite('changes in existing hashtags', () => {
+ const changesWithHashtags: ChangeInfo[] = [
+ {
+ ...createChange(),
+ _number: 1 as NumericChangeId,
+ subject: 'Subject 1',
+ hashtags: ['hashtag1' as Hashtag],
+ },
+ {
+ ...createChange(),
+ _number: 2 as NumericChangeId,
+ subject: 'Subject 2',
+ hashtags: ['hashtag2' as Hashtag],
+ },
+ ];
+ let setChangeHashtagPromises: MockPromise<string>[];
+ let setChangeHashtagStub: sinon.SinonStub;
+
+ async function resolvePromises() {
+ setChangeHashtagPromises[0].resolve('foo');
+ setChangeHashtagPromises[1].resolve('foo');
+ await element.updateComplete;
+ }
+
+ setup(async () => {
+ stubRestApi('getDetailedChangesWithActions').resolves(
+ changesWithHashtags
+ );
+ setChangeHashtagPromises = [];
+ setChangeHashtagStub = stubRestApi('setChangeHashtag');
+ for (let i = 0; i < changesWithHashtags.length; i++) {
+ const promise = mockPromise<string>();
+ setChangeHashtagPromises.push(promise);
+ setChangeHashtagStub
+ .withArgs(changesWithHashtags[i]._number, sinon.match.any)
+ .returns(promise);
+ }
+ model = new BulkActionsModel(getAppContext().restApiService);
+ model.sync(changesWithHashtags);
+
+ element = (
+ await fixture(
+ wrapInProvider(
+ html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+ bulkActionsModelToken,
+ model
+ )
+ )
+ ).querySelector('gr-change-list-hashtag-flow')!;
+
+ // select changes
+ await selectChange(changesWithHashtags[0]);
+ await selectChange(changesWithHashtags[1]);
+ await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+ await element.updateComplete;
+
+ // open flow
+ queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+ await element.updateComplete;
+ await flush();
+ });
+
+ test('renders existing-hashtags flow', () => {
+ expect(element).shadowDom.to.equal(
+ /* HTML */ `
+ <gr-button
+ id="start-flow"
+ flatten=""
+ aria-disabled="false"
+ role="button"
+ tabindex="0"
+ >Hashtag</gr-button
+ >
+ <iron-dropdown
+ aria-disabled="false"
+ vertical-align="auto"
+ horizontal-align="auto"
+ >
+ <div slot="dropdown-content">
+ <div class="chips">
+ <span role="button" aria-label="hashtag1" class="chip"
+ >hashtag1</span
+ >
+ <span role="button" aria-label="hashtag2" class="chip"
+ >hashtag2</span
+ >
+ </div>
+ <div class="footer">
+ <div class="loadingOrError"></div>
+ <div class="buttons">
+ <gr-button
+ id="apply-to-all-button"
+ flatten=""
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >Apply to all</gr-button
+ >
+ <gr-button
+ id="remove-hashtags-button"
+ flatten=""
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >Remove</gr-button
+ >
+ </div>
+ </div>
+ </div>
+ </iron-dropdown>
+ `,
+ {
+ // iron-dropdown sizing seems to vary between local & CI
+ ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+ }
+ );
+ });
+
+ test('remove single hashtag', async () => {
+ queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+ await element.updateComplete;
+ queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, '.loadingText').textContent,
+ 'Removing hashtag...'
+ );
+
+ await resolvePromises();
+ await element.updateComplete;
+
+ // not called for second change which as a different hashtag
+ assert.isTrue(setChangeHashtagStub.calledOnce);
+ assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+ changesWithHashtags[0]._number,
+ {remove: ['hashtag1']},
+ ]);
+ });
+
+ test('remove multiple hashtags', async () => {
+ queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+ queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+ await element.updateComplete;
+ queryAndAssert<GrButton>(element, '#remove-hashtags-button').click();
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, '.loadingText').textContent,
+ 'Removing hashtags...'
+ );
+
+ await resolvePromises();
+ await element.updateComplete;
+
+ // not called for second change which as a different hashtag
+ assert.isTrue(setChangeHashtagStub.calledTwice);
+ assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+ changesWithHashtags[0]._number,
+ {remove: ['hashtag1', 'hashtag2']},
+ ]);
+ assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+ changesWithHashtags[1]._number,
+ {remove: ['hashtag1', 'hashtag2']},
+ ]);
+ });
+
+ test('can only apply a single hashtag', async () => {
+ assert.isTrue(
+ queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+ );
+
+ queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+ await element.updateComplete;
+
+ assert.isFalse(
+ queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+ );
+
+ queryAll<HTMLSpanElement>(element, 'span.chip')[1].click();
+ await element.updateComplete;
+
+ assert.isTrue(
+ queryAndAssert<GrButton>(element, '#apply-to-all-button').disabled
+ );
+ });
+
+ test('applies hashtag to all changes', async () => {
+ queryAll<HTMLSpanElement>(element, 'span.chip')[0].click();
+ await element.updateComplete;
+
+ queryAndAssert<GrButton>(element, '#apply-to-all-button').click();
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, '.loadingText').textContent,
+ 'Applying hashtag to all'
+ );
+
+ await resolvePromises();
+ await element.updateComplete;
+
+ assert.isTrue(setChangeHashtagStub.calledTwice);
+ assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+ changesWithHashtags[0]._number,
+ {add: ['hashtag1']},
+ ]);
+ assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+ changesWithHashtags[1]._number,
+ {add: ['hashtag1']},
+ ]);
+ });
+ });
+
+ suite('change have no existing hashtags', () => {
+ const changesWithNoHashtags: ChangeInfo[] = [
+ {
+ ...createChange(),
+ _number: 1 as NumericChangeId,
+ subject: 'Subject 1',
+ },
+ {
+ ...createChange(),
+ _number: 2 as NumericChangeId,
+ subject: 'Subject 2',
+ },
+ ];
+ let setChangeHashtagPromises: MockPromise<string>[];
+ let setChangeHashtagStub: sinon.SinonStub;
+
+ async function resolvePromises() {
+ setChangeHashtagPromises[0].resolve('foo');
+ setChangeHashtagPromises[1].resolve('foo');
+ await element.updateComplete;
+ }
+
+ setup(async () => {
+ stubRestApi('getDetailedChangesWithActions').resolves(
+ changesWithNoHashtags
+ );
+ setChangeHashtagPromises = [];
+ setChangeHashtagStub = stubRestApi('setChangeHashtag');
+ for (let i = 0; i < changesWithNoHashtags.length; i++) {
+ const promise = mockPromise<string>();
+ setChangeHashtagPromises.push(promise);
+ setChangeHashtagStub
+ .withArgs(changesWithNoHashtags[i]._number, sinon.match.any)
+ .returns(promise);
+ }
+
+ model = new BulkActionsModel(getAppContext().restApiService);
+ model.sync(changesWithNoHashtags);
+
+ element = (
+ await fixture(
+ wrapInProvider(
+ html`<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>`,
+ bulkActionsModelToken,
+ model
+ )
+ )
+ ).querySelector('gr-change-list-hashtag-flow')!;
+
+ // select changes
+ await selectChange(changesWithNoHashtags[0]);
+ await selectChange(changesWithNoHashtags[1]);
+ await waitUntilObserved(model.selectedChanges$, s => s.length === 2);
+ await element.updateComplete;
+
+ // open flow
+ queryAndAssert<GrButton>(element, 'gr-button#start-flow').click();
+ await element.updateComplete;
+ await flush();
+ });
+
+ test('renders no-existing-hashtags flow', () => {
+ expect(element).shadowDom.to.equal(
+ /* HTML */ `
+ <gr-button
+ id="start-flow"
+ flatten=""
+ aria-disabled="false"
+ role="button"
+ tabindex="0"
+ >Hashtag</gr-button
+ >
+ <iron-dropdown
+ aria-disabled="false"
+ vertical-align="auto"
+ horizontal-align="auto"
+ >
+ <div slot="dropdown-content">
+ <gr-autocomplete
+ placeholder="Type hashtag name to create or filter hashtags"
+ show-blue-focus-border=""
+ ></gr-autocomplete>
+ <div class="footer">
+ <div class="loadingOrError"></div>
+ <div class="buttons">
+ <gr-button
+ id="create-new-hashtag-button"
+ flatten=""
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >Create new hashtag</gr-button
+ >
+ <gr-button
+ id="apply-hashtag-button"
+ flatten=""
+ aria-disabled="true"
+ disabled=""
+ role="button"
+ tabindex="-1"
+ >Apply</gr-button
+ >
+ </div>
+ </div>
+ </div>
+ </iron-dropdown>
+ `,
+ {
+ // iron-dropdown sizing seems to vary between local & CI
+ ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+ }
+ );
+ });
+
+ test('create new hashtag', async () => {
+ const getHashtagsStub = stubRestApi(
+ 'getChangesWithSimilarHashtag'
+ ).resolves([]);
+ const autocomplete = queryAndAssert<GrAutocomplete>(
+ element,
+ 'gr-autocomplete'
+ );
+ autocomplete.focus();
+ autocomplete.text = 'foo';
+ await element.updateComplete;
+ await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+ assert.isTrue(
+ queryAndAssert<GrButton>(element, '#apply-hashtag-button').disabled
+ );
+
+ queryAndAssert<GrButton>(element, '#create-new-hashtag-button').click();
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, '.loadingText').textContent,
+ 'Creating hashtag...'
+ );
+
+ await resolvePromises();
+ await element.updateComplete;
+
+ assert.isTrue(setChangeHashtagStub.calledTwice);
+ assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+ changesWithNoHashtags[0]._number,
+ {add: ['foo']},
+ ]);
+ assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+ changesWithNoHashtags[1]._number,
+ {add: ['foo']},
+ ]);
+ });
+
+ test('apply hashtag', async () => {
+ const getHashtagsStub = stubRestApi(
+ 'getChangesWithSimilarHashtag'
+ ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]);
+ const autocomplete = queryAndAssert<GrAutocomplete>(
+ element,
+ 'gr-autocomplete'
+ );
+
+ autocomplete.focus();
+ autocomplete.text = 'foo';
+ await element.updateComplete;
+ await waitUntilCalled(getHashtagsStub, 'getHashtagsStub');
+ assert.isTrue(
+ queryAndAssert<GrButton>(element, '#create-new-hashtag-button').disabled
+ );
+
+ queryAndAssert<GrButton>(element, '#apply-hashtag-button').click();
+ await element.updateComplete;
+
+ assert.equal(
+ queryAndAssert(element, '.loadingText').textContent,
+ 'Applying hashtag...'
+ );
+
+ await resolvePromises();
+
+ assert.isTrue(setChangeHashtagStub.calledTwice);
+ assert.deepEqual(setChangeHashtagStub.firstCall.args, [
+ changesWithNoHashtags[0]._number,
+ {add: ['foo']},
+ ]);
+ assert.deepEqual(setChangeHashtagStub.secondCall.args, [
+ changesWithNoHashtags[1]._number,
+ {add: ['foo']},
+ ]);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index a9c6526..112e688 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -21,7 +21,6 @@
import {ValueChangedEvent} from '../../../types/events';
import {classMap} from 'lit/directives/class-map';
import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {pluralize} from '../../../utils/string-util';
import {ProgressStatus} from '../../../constants/constants';
import {allSettled} from '../../../utils/async-util';
@@ -31,8 +30,6 @@
@state() private topicToAdd: TopicName = '' as TopicName;
- @state() private selectedExistingTopics: Set<TopicName> = new Set();
-
@state() private existingTopicSuggestions: TopicName[] = [];
@state() private loadingText?: string;
@@ -46,6 +43,8 @@
@query('iron-dropdown') private dropdown?: IronDropdownElement;
+ private selectedExistingTopics: Set<TopicName> = new Set();
+
private getBulkActionsModel = resolve(this, bulkActionsModelToken);
private restApiService = getAppContext().restApiService;
@@ -123,14 +122,19 @@
}
override render() {
+ const isFlowDisabled = this.selectedChanges.length === 0;
return html`
- <gr-button id="start-flow" flatten @click=${this.toggleDropdown}
+ <gr-button
+ id="start-flow"
+ flatten
+ @click=${this.toggleDropdown}
+ .disabled=${isFlowDisabled}
>Topic</gr-button
>
<iron-dropdown
.horizontalAlign=${'auto'}
.verticalAlign=${'auto'}
- .verticalOffset=${24 /* roughly line height in pixels */}
+ .verticalOffset=${24}
@opened-changed=${(e: CustomEvent) =>
(this.isDropdownOpen = e.detail.value)}
>
@@ -191,6 +195,8 @@
};
return html`
<span
+ role="button"
+ aria-label=${name as string}
class=${classMap(chipClasses)}
@click=${() => this.toggleExistingTopicSelected(name)}
>
@@ -260,18 +266,30 @@
private toggleDropdown() {
if (this.isDropdownOpen) {
- this.isDropdownOpen = false;
- this.dropdown?.close();
+ this.closeDropdown();
} else {
- this.topicToAdd = '' as TopicName;
- this.selectedExistingTopics = new Set();
- this.overallProgress = ProgressStatus.NOT_STARTED;
- this.errorText = undefined;
- this.isDropdownOpen = true;
- this.dropdown?.open();
+ this.reset();
+ 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.isDropdownOpen = true;
+ this.dropdown?.open();
+ }
+
private async getTopicSuggestions(
query: string
): Promise<AutocompleteSuggestion[]> {
@@ -288,10 +306,9 @@
}
private removeTopics() {
- this.loadingText = `Removing ${pluralize(
- this.selectedExistingTopics.size,
- 'topic'
- )}...`;
+ this.loadingText = `Removing topic${
+ this.selectedExistingTopics.size > 1 ? 's' : ''
+ }...`;
this.trackPromises(
this.selectedChanges
.filter(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 677aeb4..fd78479 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -210,8 +210,12 @@
>
<div slot="dropdown-content">
<div class="chips">
- <span class="chip">topic1</span>
- <span class="chip">topic2</span>
+ <span role="button" aria-label="topic1" class="chip"
+ >topic1</span
+ >
+ <span role="button" aria-label="topic2" class="chip"
+ >topic2</span
+ >
</div>
<div class="footer">
<div class="loadingOrError"></div>
@@ -254,7 +258,7 @@
assert.equal(
queryAndAssert(element, '.loadingText').textContent,
- 'Removing 1 topic...'
+ 'Removing topic...'
);
await resolvePromises();
@@ -277,7 +281,7 @@
assert.equal(
queryAndAssert(element, '.loadingText').textContent,
- 'Removing 2 topics...'
+ 'Removing topics...'
);
await resolvePromises();
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 8e6a147..2b4fc60 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -1817,7 +1817,7 @@
}
getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
- const query = [`intopic:"${topic}"`].join(' ');
+ const query = `intopic:"${topic}"`;
return this._restApiHelper.fetchJSON({
url: '/changes/',
params: {q: query},
@@ -1825,6 +1825,17 @@
}) as Promise<ChangeInfo[] | undefined>;
}
+ getChangesWithSimilarHashtag(
+ hashtag: string
+ ): Promise<ChangeInfo[] | undefined> {
+ const query = `inhashtag:"${hashtag}"`;
+ return this._restApiHelper.fetchJSON({
+ url: '/changes/',
+ params: {q: query},
+ anonymizedUrl: '/changes/inhashtag:*',
+ }) as Promise<ChangeInfo[] | undefined>;
+ }
+
getReviewedFiles(
changeNum: NumericChangeId,
patchNum: PatchSetNum
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index e727216..0ea561f 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -634,6 +634,9 @@
}
): Promise<ChangeInfo[] | undefined>;
getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+ getChangesWithSimilarHashtag(
+ hashtag: string
+ ): Promise<ChangeInfo[] | undefined>;
hasPendingDiffDrafts(): number;
awaitPendingDiffDrafts(): Promise<void>;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index d91b438..ae8545a 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -280,6 +280,9 @@
getChangesWithSimilarTopic(): Promise<ChangeInfo[] | undefined> {
return Promise.resolve([]);
},
+ getChangesWithSimilarHashtag(): Promise<ChangeInfo[] | undefined> {
+ return Promise.resolve([]);
+ },
getConfig(): Promise<ServerInfo | undefined> {
return Promise.resolve(createServerInfo());
},