Merge "Fix focus to commit message"
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-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 07bc31d..9233dbc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -77,10 +77,9 @@
margin-top: var(--spacing-m);
}
.vote-type {
- margin-bottom: var(--spacing-m);
+ margin-bottom: var(--spacing-s);
margin-top: 0;
display: table-caption;
- font-weight: 600; /* TODO: create css variable for it */
}
.main-heading {
margin-bottom: var(--spacing-m);
@@ -159,7 +158,7 @@
permittedLabels?: LabelNameToValuesMap
) {
return html` <div class="scoresTable newSubmitRequirements">
- <h3 class="vote-type">${labels.length ? heading : nothing}</h3>
+ <h3 class="heading-4 vote-type">${labels.length ? heading : nothing}</h3>
${labels
.filter(
label =>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 0447713..1372eb8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -157,7 +157,7 @@
</div>
<div slot="main">
<div class="newSubmitRequirements scoresTable">
- <h3 class="vote-type">Submit requirements votes</h3>
+ <h3 class="heading-4 vote-type">Submit requirements votes</h3>
<gr-label-score-row name="A"> </gr-label-score-row>
<gr-label-score-row name="B"> </gr-label-score-row>
<gr-label-score-row name="C"> </gr-label-score-row>
@@ -165,7 +165,7 @@
</gr-label-score-row>
</div>
<div class="newSubmitRequirements scoresTable">
- <h3 class="vote-type">Trigger Votes</h3>
+ <h3 class="heading-4 vote-type">Trigger Votes</h3>
<gr-label-score-row name="change1OnlyTriggerLabelE">
</gr-label-score-row>
</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-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 95e9c87..6d6722e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -24,7 +24,6 @@
import {
GrReviewerSuggestionsProvider,
ReviewerSuggestionsProvider,
- SUGGESTIONS_PROVIDERS_USERS_TYPES,
} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import '../../shared/gr-account-list/gr-account-list';
import {getOverallStatus} from '../../../utils/bulk-flow-util';
@@ -34,11 +33,6 @@
import {AccountInputDetail} from '../../shared/gr-account-list/gr-account-list';
import '@polymer/iron-icon/iron-icon';
-const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE = {
- REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
- CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
-};
-
@customElement('gr-change-list-reviewer-flow')
export class GrChangeListReviewerFlow extends LitElement {
@state() private selectedChanges: ChangeInfo[] = [];
@@ -403,7 +397,7 @@
): ReviewerSuggestionsProvider {
const suggestionsProvider = new GrReviewerSuggestionsProvider(
this.restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state],
+ state,
this.serverConfig,
this.isLoggedIn,
...this.selectedChanges.map(change => change._number)
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/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 9af14f5..95ad376 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -173,6 +173,8 @@
@state() private queryTopic?: AutocompleteQuery;
+ @state() private queryHashtag?: AutocompleteQuery;
+
private restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
@@ -182,6 +184,7 @@
constructor() {
super();
this.queryTopic = (input: string) => this.getTopicSuggestions(input);
+ this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
}
static override styles = [
@@ -689,6 +692,8 @@
.readOnly=${this.hashtagReadOnly}
@changed=${this.handleHashtagChanged}
showAsEditPencil
+ autocomplete
+ .query=${this.queryHashtag}
></gr-editable-label>
`
)}
@@ -1192,6 +1197,22 @@
);
}
+ private getHashtagSuggestions(
+ input: string
+ ): Promise<AutocompleteSuggestion[]> {
+ return this.restApiService
+ .getChangesWithSimilarHashtag(input)
+ .then(response =>
+ (response ?? [])
+ .flatMap(change => change.hashtags ?? [])
+ .filter(notUndefined)
+ .filter(unique)
+ .map(hashtag => {
+ return {name: hashtag, value: hashtag};
+ })
+ );
+ }
+
private showNewSubmitRequirements() {
return showNewSubmitRequirements(this.flagsService, this.change);
}
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 c35b6b3..c3eea40b 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
@@ -27,10 +27,7 @@
import '../gr-label-scores/gr-label-scores';
import '../gr-thread-list/gr-thread-list';
import '../../../styles/shared-styles';
-import {
- GrReviewerSuggestionsProvider,
- SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {getAppContext} from '../../../services/app-context';
import {
ChangeStatus,
@@ -1491,7 +1488,7 @@
const jsonPromise = this.restApiService.getResponseObject(response.clone());
return jsonPromise.then((parsed: ParsedJSON) => {
const result = parsed as ReviewResult;
- // Only perform custom error handling for 400s and a parseable
+ // Only perform custom error handling for 400s and a parsable
// ReviewResult response.
if (response.status === 400 && result && result.reviewers) {
const errors: string[] = [];
@@ -2090,7 +2087,7 @@
if (!change) return;
const provider = new GrReviewerSuggestionsProvider(
this.restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+ ReviewerState.REVIEWER,
this.serverConfig,
this.isLoggedIn,
change._number
@@ -2102,7 +2099,7 @@
if (!change) return;
const provider = new GrReviewerSuggestionsProvider(
this.restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
+ ReviewerState.CC,
this.serverConfig,
this.isLoggedIn,
change._number
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 4493e8d..ce5ef4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -292,7 +292,7 @@
class="attentionIcon"
icon="gr-icons:attention"
></iron-icon>
- <span> ${this.computePronoun()} turn to take this action. </span>
+ <span> ${this.computePronoun()} turn to take action. </span>
<a
href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
target="_blank"
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index f39fa32..135c1aa 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -102,7 +102,7 @@
* Type Safety
* ---
*
- * Dependency injection is guaranteed npmtype-safe by construction due to the
+ * Dependency injection is guaranteed type-safe by construction due to the
* typing of the token used to tie together dependency providers and dependency
* consumers.
*
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 95e7f2a..5cb57aa 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -33,13 +33,7 @@
import {allSettled, isFulfilled} from '../../utils/async-util';
import {notUndefined} from '../../types/types';
import {accountKey} from '../../utils/account-util';
-
-// TODO(TS): enum name doesn't follow typescript style guide rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
- REVIEWER = 'reviewers',
- CC = 'ccs',
-}
+import {ReviewerState} from '../../api/rest-api';
export interface ReviewerSuggestionsProvider {
getSuggestions(input: string): Promise<Suggestion[]>;
@@ -55,7 +49,7 @@
constructor(
private restApi: RestApiService,
- private type: SUGGESTIONS_PROVIDERS_USERS_TYPES,
+ private type: ReviewerState.REVIEWER | ReviewerState.CC,
private config: ServerInfo | undefined,
private loggedIn: boolean,
...changeNumbers: NumericChangeId[]
@@ -112,8 +106,8 @@
private getSuggestionsForChange(
changeNumber: NumericChangeId,
input: string
- ): Promise<Suggestion[] | undefined> {
- return this.type === SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+ ): Promise<SuggestedReviewerInfo[] | undefined> {
+ return this.type === ReviewerState.REVIEWER
? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
: this.restApi.getChangeSuggestedCCs(changeNumber, input);
}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index 3d1a15f..3dc30dd 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -16,10 +16,7 @@
*/
import '../../test/common-test-setup-karma';
-import {
- GrReviewerSuggestionsProvider,
- SUGGESTIONS_PROVIDERS_USERS_TYPES,
-} from './gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
import {getAppContext} from '../../services/app-context';
import {stubRestApi} from '../../test/test-utils';
import {
@@ -27,6 +24,7 @@
GroupId,
GroupName,
NumericChangeId,
+ ReviewerState,
} from '../../api/rest-api';
import {
SuggestedReviewerAccountInfo,
@@ -70,7 +68,7 @@
]);
provider = new GrReviewerSuggestionsProvider(
getAppContext().restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+ ReviewerState.REVIEWER,
createServerInfo(),
true,
change._number
@@ -90,7 +88,7 @@
// not logged in
provider = new GrReviewerSuggestionsProvider(
getAppContext().restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+ ReviewerState.REVIEWER,
createServerInfo(),
false,
change._number
@@ -108,7 +106,7 @@
.resolves([suggestion2, suggestion3]);
provider = new GrReviewerSuggestionsProvider(
getAppContext().restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+ ReviewerState.REVIEWER,
createServerInfo(),
true,
...[change._number, 43 as NumericChangeId]
@@ -128,7 +126,7 @@
.resolves([suggestion2, suggestion3]);
provider = new GrReviewerSuggestionsProvider(
getAppContext().restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
+ ReviewerState.CC,
createServerInfo(),
true,
...[change._number, 43 as NumericChangeId]
@@ -172,7 +170,7 @@
provider = new GrReviewerSuggestionsProvider(
getAppContext().restApiService,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
+ ReviewerState.REVIEWER,
{
...createServerInfo(),
user: {
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());
},