Merge "Minor comment fix."
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index c45de05..13873ed 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -29,10 +29,10 @@
. Download the desired Gerrit archive.
To view previous archives, see
-link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.1.3:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.5.1:
....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.1.3.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.5.1.war
....
NOTE: To build and install Gerrit from the source files, see
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index 5a7b3cb..506d292 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -100,7 +100,11 @@
.map(
id -> {
try {
- return changeDataFactory.create(projectName, id);
+ ChangeData cd = changeDataFactory.create(projectName, id);
+ cd.notes(); // Make sure notes are available. This will trigger loading notes and
+ // throw an exception in case the change is corrupt and can't be loaded. It will
+ // then be omitted from the result.
+ return cd;
} catch (Exception e) {
// We drop changes that we can't load. The repositories contain 'dead' change refs
// and we want to overall operation to continue.
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 58eec3b..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,15 +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: Record<
- ReviewerState,
- SUGGESTIONS_PROVIDERS_USERS_TYPES
-> = {
- REVIEWER: SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER,
- CC: SUGGESTIONS_PROVIDERS_USERS_TYPES.CC,
- REMOVED: SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY,
-};
-
@customElement('gr-change-list-reviewer-flow')
export class GrChangeListReviewerFlow extends LitElement {
@state() private selectedChanges: ChangeInfo[] = [];
@@ -80,6 +70,8 @@
private restApiService = getAppContext().restApiService;
+ private isLoggedIn = false;
+
static override get styles() {
return css`
gr-dialog {
@@ -128,6 +120,11 @@
this.getConfigModel().serverConfig$,
serverConfig => (this.serverConfig = serverConfig)
);
+ subscribe(
+ this,
+ getAppContext().userModel.loggedIn$,
+ isLoggedIn => (this.isLoggedIn = isLoggedIn)
+ );
}
override render() {
@@ -275,7 +272,7 @@
ProgressStatus.NOT_STARTED,
])
);
- for (const state of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+ for (const state of [ReviewerState.REVIEWER, ReviewerState.CC] as const) {
this.updatedAccountsByReviewerState.set(
state,
this.getCurrentAccounts(state)
@@ -396,15 +393,15 @@
}
private createSuggestionsProvider(
- state: ReviewerState
+ state: ReviewerState.CC | ReviewerState.REVIEWER
): ReviewerSuggestionsProvider {
- const suggestionsProvider = GrReviewerSuggestionsProvider.create(
+ const suggestionsProvider = new GrReviewerSuggestionsProvider(
this.restApiService,
- // TODO: fan out and get suggestions allowed by all changes
- this.selectedChanges[0]._number,
- SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE[state]
+ state,
+ this.serverConfig,
+ this.isLoggedIn,
+ ...this.selectedChanges.map(change => change._number)
);
- suggestionsProvider.init();
return suggestionsProvider;
}
}
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-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 9f33990..c03121d 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -306,7 +306,7 @@
super.disconnectedCallback();
}
- scrollToMessage(messageID: string) {
+ async scrollToMessage(messageID: string) {
const selector = `[data-message-id="${messageID}"]`;
const el = this.shadowRoot!.querySelector(selector) as
| GrMessage
@@ -325,6 +325,7 @@
}
el.message = {...el.message, expanded: true};
+ await el.updateComplete;
let top = el.offsetTop;
for (
let offsetParent = el.offsetParent as HTMLElement | null;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 30dd257..b9cb616 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -222,7 +222,7 @@
assert.isNotOk(query(element, '.showAllActivityToggle'));
});
- test('scroll to message', () => {
+ test('scroll to message', async () => {
const allMessageEls = getMessages();
for (const message of allMessageEls) {
assertIsDefined(message.message);
@@ -232,7 +232,7 @@
const scrollToStub = sinon.stub(window, 'scrollTo');
const highlightStub = sinon.stub(element, '_highlightEl');
- element.scrollToMessage('invalid');
+ await element.scrollToMessage('invalid');
for (const message of allMessageEls) {
assertIsDefined(message.message);
@@ -243,7 +243,7 @@
}
const messageID = messages[1].id;
- element.scrollToMessage(messageID);
+ await element.scrollToMessage(messageID);
assert.isTrue(
queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
.message?.expanded
@@ -253,16 +253,16 @@
assert.isTrue(highlightStub.calledOnce);
});
- test('scroll to message offscreen', () => {
+ test('scroll to message offscreen', async () => {
const scrollToStub = sinon.stub(window, 'scrollTo');
const highlightStub = sinon.stub(element, '_highlightEl');
element.messages = generateRandomMessages(25);
- flush();
+ await element.updateComplete;
assert.isFalse(scrollToStub.called);
assert.isFalse(highlightStub.called);
const messageID = element.messages[1].id;
- element.scrollToMessage(messageID);
+ await element.scrollToMessage(messageID);
assert.isTrue(scrollToStub.calledOnce);
assert.isTrue(highlightStub.calledOnce);
assert.isTrue(
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 95572a8..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,
@@ -118,6 +115,7 @@
import {classMap} from 'lit/directives/class-map';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {customElement, property, state, query} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -347,6 +345,8 @@
storeTask?: DelayedTask;
+ private isLoggedIn = false;
+
/** Called in disconnectedCallback. */
private cleanups: (() => void)[] = [];
@@ -687,6 +687,12 @@
this.addEventListener('remove-reviewer', e => {
this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
});
+
+ subscribe(
+ this,
+ getAppContext().userModel.loggedIn$,
+ isLoggedIn => (this.isLoggedIn = isLoggedIn)
+ );
}
override disconnectedCallback() {
@@ -1482,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[] = [];
@@ -2079,23 +2085,25 @@
getReviewerSuggestionsProvider(change?: ChangeInfo) {
if (!change) return;
- const provider = GrReviewerSuggestionsProvider.create(
+ const provider = new GrReviewerSuggestionsProvider(
this.restApiService,
- change._number,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+ ReviewerState.REVIEWER,
+ this.serverConfig,
+ this.isLoggedIn,
+ change._number
);
- provider.init();
return provider;
}
getCcSuggestionsProvider(change?: ChangeInfo) {
if (!change) return;
- const provider = GrReviewerSuggestionsProvider.create(
+ const provider = new GrReviewerSuggestionsProvider(
this.restApiService,
- change._number,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+ ReviewerState.CC,
+ this.serverConfig,
+ this.isLoggedIn,
+ change._number
);
- provider.init();
return provider;
}
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 659d3f0..135c1aa 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -133,16 +133,35 @@
*/
export type Provider<T> = () => T;
+// Symbols to cache the providers and resolvers to avoid duplicate registration.
+const PROVIDERS_SYMBOL = Symbol('providers');
+const RESOLVERS_SYMBOL = Symbol('resolvers');
+
+interface Registrations {
+ [PROVIDERS_SYMBOL]?: Map<
+ DependencyToken<unknown>,
+ DependencyProvider<unknown>
+ >;
+ [RESOLVERS_SYMBOL]?: Map<DependencyToken<unknown>, Provider<unknown>>;
+}
/**
* A producer of a dependency expresses this as a need that results in a promise
* for the given dependency.
*/
export function provide<T>(
- host: ReactiveControllerHost & HTMLElement,
+ host: ReactiveControllerHost & HTMLElement & Registrations,
dependency: DependencyToken<T>,
provider: Provider<T>
) {
- host.addController(new DependencyProvider<T>(host, dependency, provider));
+ const hostProviders = (host[PROVIDERS_SYMBOL] ||= new Map());
+ const oldController = hostProviders.get(dependency);
+ if (oldController) {
+ host.removeController(oldController);
+ oldController.hostDisconnected();
+ }
+ const controller = new DependencyProvider<T>(host, dependency, provider);
+ hostProviders.set(dependency, provider);
+ host.addController(controller);
}
/**
@@ -151,12 +170,18 @@
* the injected value.
*/
export function resolve<T>(
- host: ReactiveControllerHost & HTMLElement,
+ host: ReactiveControllerHost & HTMLElement & Registrations,
dependency: DependencyToken<T>
): Provider<T> {
- const controller = new DependencySubscriber(host, dependency);
- host.addController(controller);
- return () => controller.get();
+ const hostResolvers = (host[RESOLVERS_SYMBOL] ||= new Map());
+ let resolver = hostResolvers.get(dependency);
+ if (!resolver) {
+ const controller = new DependencySubscriber(host, dependency);
+ host.addController(controller);
+ resolver = () => controller.get();
+ hostResolvers.set(dependency, resolver);
+ }
+ return resolver;
}
/**
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 d0f79f4..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
@@ -28,25 +28,14 @@
SuggestedReviewerInfo,
Suggestion,
} from '../../types/common';
-import {assertNever} from '../../utils/common-util';
+import {assertNever, intersection} from '../../utils/common-util';
import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
-
-// TODO(TS): enum name doesn't follow typescript style guid rules
-// Rename it
-export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
- REVIEWER = 'reviewers',
- CC = 'ccs',
- ANY = 'any',
-}
-
-export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
- return (s as AccountInfo)._account_id !== undefined;
-}
-
-type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+import {allSettled, isFulfilled} from '../../utils/async-util';
+import {notUndefined} from '../../types/types';
+import {accountKey} from '../../utils/account-util';
+import {ReviewerState} from '../../api/rest-api';
export interface ReviewerSuggestionsProvider {
- init(): void;
getSuggestions(input: string): Promise<Suggestion[]>;
makeSuggestionItem(
suggestion: Suggestion
@@ -56,66 +45,33 @@
export class GrReviewerSuggestionsProvider
implements ReviewerSuggestionsProvider
{
- static create(
- restApi: RestApiService,
- changeNumber: NumericChangeId,
- userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+ private changeNumbers: NumericChangeId[];
+
+ constructor(
+ private restApi: RestApiService,
+ private type: ReviewerState.REVIEWER | ReviewerState.CC,
+ private config: ServerInfo | undefined,
+ private loggedIn: boolean,
+ ...changeNumbers: NumericChangeId[]
) {
- switch (userType) {
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
- return new GrReviewerSuggestionsProvider(restApi, input =>
- restApi.getChangeSuggestedReviewers(changeNumber, input)
- );
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
- return new GrReviewerSuggestionsProvider(restApi, input =>
- restApi.getChangeSuggestedCCs(changeNumber, input)
- );
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
- return new GrReviewerSuggestionsProvider(restApi, input =>
- restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
- );
- default:
- throw new Error(`Unknown users type: ${userType}`);
- }
+ this.changeNumbers = changeNumbers;
}
- private initPromise?: Promise<void>;
+ async getSuggestions(input: string): Promise<Suggestion[]> {
+ if (!this.loggedIn) return [];
- config?: ServerInfo;
-
- loggedIn = false;
-
- private initialized = false;
-
- private constructor(
- private readonly _restAPI: RestApiService,
- private readonly _apiCall: ApiCallCallback
- ) {}
-
- init() {
- if (this.initPromise) {
- return this.initPromise;
- }
- const getConfigPromise = this._restAPI.getConfig().then(cfg => {
- this.config = cfg;
- });
- const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
- this.loggedIn = loggedIn;
- });
- this.initPromise = Promise.all([getConfigPromise, getLoggedInPromise]).then(
- () => {
- this.initialized = true;
- }
+ const allResults = await allSettled(
+ this.changeNumbers.map(changeNumber =>
+ this.getSuggestionsForChange(changeNumber, input)
+ )
);
- return this.initPromise;
- }
-
- getSuggestions(input: string): Promise<Suggestion[]> {
- if (!this.initialized || !this.loggedIn) {
- return Promise.resolve([]);
- }
-
- return this._apiCall(input).then(reviewers => reviewers || []);
+ const allSuggestions = allResults
+ .filter(isFulfilled)
+ .map(result => result.value)
+ .filter(notUndefined);
+ return intersection(allSuggestions, (s1, s2) =>
+ this.areSameSuggestions(s1, s2)
+ );
}
makeSuggestionItem(
@@ -137,7 +93,7 @@
};
}
- if (isAccountSuggestions(suggestion)) {
+ if (this.isAccountSuggestion(suggestion)) {
// Reviewer is an account suggestion from getSuggestedAccounts.
return {
name: getAccountDisplayName(this.config, suggestion),
@@ -146,4 +102,28 @@
}
assertNever(suggestion, 'Received an incorrect suggestion');
}
+
+ private getSuggestionsForChange(
+ changeNumber: NumericChangeId,
+ input: string
+ ): Promise<SuggestedReviewerInfo[] | undefined> {
+ return this.type === ReviewerState.REVIEWER
+ ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
+ : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+ }
+
+ private areSameSuggestions(a: Suggestion, b: Suggestion): boolean {
+ if (isReviewerAccountSuggestion(a) && isReviewerAccountSuggestion(b)) {
+ return accountKey(a.account) === accountKey(b.account);
+ } else if (isReviewerGroupSuggestion(a) && isReviewerGroupSuggestion(b)) {
+ return a.group.id === b.group.id;
+ } else if (this.isAccountSuggestion(a) && this.isAccountSuggestion(b)) {
+ return accountKey(a) === accountKey(b);
+ }
+ return false;
+ }
+
+ private isAccountSuggestion(s: Suggestion): s is AccountInfo {
+ return (s as AccountInfo)._account_id !== undefined;
+ }
}
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 757bcca..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,244 +16,197 @@
*/
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 {
- AccountId,
- AccountInfo,
ChangeInfo,
- EmailAddress,
GroupId,
GroupName,
NumericChangeId,
+ ReviewerState,
} from '../../api/rest-api';
-import {SuggestedReviewerInfo} from '../../types/common';
-import {createChange, createServerInfo} from '../../test/test-data-generators';
+import {
+ SuggestedReviewerAccountInfo,
+ SuggestedReviewerGroupInfo,
+} from '../../types/common';
+import {
+ createAccountDetailWithIdNameAndEmail,
+ createChange,
+ createServerInfo,
+} from '../../test/test-data-generators';
suite('GrReviewerSuggestionsProvider tests', () => {
- let _nextAccountId = 0;
- function makeAccount(opt_status?: string): AccountInfo {
- const accountId = ++_nextAccountId;
- return {
- _account_id: accountId as AccountId,
- name: `name ${accountId}`,
- email: `email ${accountId}` as EmailAddress,
- status: opt_status,
- };
- }
- let _nextAccountId2 = 0;
- function makeAccount2(opt_status?: string): AccountInfo {
- const accountId2 = ++_nextAccountId2;
- return {
- _account_id: accountId2 as AccountId,
- name: `name ${accountId2}`,
- status: opt_status,
- };
- }
-
- let owner: AccountInfo;
- let existingReviewer1: AccountInfo;
- let existingReviewer2: AccountInfo;
- let suggestion1: SuggestedReviewerInfo;
- let suggestion2: SuggestedReviewerInfo;
- let suggestion3: SuggestedReviewerInfo;
+ const suggestion1: SuggestedReviewerAccountInfo = {
+ account: createAccountDetailWithIdNameAndEmail(3),
+ count: 1,
+ };
+ const suggestion2: SuggestedReviewerAccountInfo = {
+ account: createAccountDetailWithIdNameAndEmail(4),
+ count: 1,
+ };
+ const suggestion3: SuggestedReviewerGroupInfo = {
+ group: {
+ id: 'suggested group id' as GroupId,
+ name: 'suggested group' as GroupName,
+ },
+ count: 4,
+ };
+ const change: ChangeInfo = createChange();
+ let getChangeSuggestedReviewersStub: sinon.SinonStub;
+ let getChangeSuggestedCCsStub: sinon.SinonStub;
let provider: GrReviewerSuggestionsProvider;
- let redundantSuggestion1: SuggestedReviewerInfo;
- let redundantSuggestion2: SuggestedReviewerInfo;
- let redundantSuggestion3: SuggestedReviewerInfo;
- let change: ChangeInfo;
-
- setup(async () => {
- owner = makeAccount();
- existingReviewer1 = makeAccount();
- existingReviewer2 = makeAccount();
- suggestion1 = {account: makeAccount(), count: 1};
- suggestion2 = {account: makeAccount(), count: 1};
- suggestion3 = {
- group: {
- id: 'suggested group id' as GroupId,
- name: 'suggested group' as GroupName,
- },
- count: 1,
- };
-
- stubRestApi('getConfig').resolves(createServerInfo());
-
- change = {
- ...createChange(),
- _number: 42 as NumericChangeId,
- owner,
- reviewers: {
- CC: [existingReviewer1],
- REVIEWER: [existingReviewer2],
- },
- };
-
- await flush();
+ setup(() => {
+ getChangeSuggestedReviewersStub = stubRestApi(
+ 'getChangeSuggestedReviewers'
+ ).resolves([suggestion1, suggestion2, suggestion3]);
+ getChangeSuggestedCCsStub = stubRestApi('getChangeSuggestedCCs').resolves([
+ suggestion1,
+ suggestion2,
+ suggestion3,
+ ]);
+ provider = new GrReviewerSuggestionsProvider(
+ getAppContext().restApiService,
+ ReviewerState.REVIEWER,
+ createServerInfo(),
+ true,
+ change._number
+ );
});
- suite('allowAnyUser set to false', () => {
- setup(async () => {
- provider = GrReviewerSuggestionsProvider.create(
- getAppContext().restApiService,
- change._number,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
- );
- await provider.init();
- });
- suite('stubbed values for _getReviewerSuggestions', () => {
- let getChangeSuggestedReviewersStub: sinon.SinonStub;
- setup(() => {
- getChangeSuggestedReviewersStub = stubRestApi(
- 'getChangeSuggestedReviewers'
- ).callsFake(() => {
- redundantSuggestion1 = {account: existingReviewer1, count: 1};
- redundantSuggestion2 = {account: existingReviewer2, count: 1};
- redundantSuggestion3 = {account: owner, count: 1};
- return Promise.resolve([
- redundantSuggestion1,
- redundantSuggestion2,
- redundantSuggestion3,
- suggestion1,
- suggestion2,
- suggestion3,
- ]);
- });
- });
+ test('getSuggestions', async () => {
+ const reviewers = await provider.getSuggestions('');
- test('makeSuggestionItem formats account or group accordingly', () => {
- let account = makeAccount();
- const account3 = makeAccount2();
- let suggestion = provider.makeSuggestionItem({account, count: 1});
- assert.deepEqual(suggestion, {
- name: `${account.name} <${account.email}>`,
- value: {account, count: 1},
- });
-
- const group = {name: 'test' as GroupName, id: '5' as GroupId};
- suggestion = provider.makeSuggestionItem({group, count: 1});
- assert.deepEqual(suggestion, {
- name: `${group.name} (group)`,
- value: {group, count: 1},
- });
-
- suggestion = provider.makeSuggestionItem(account);
- assert.deepEqual(suggestion, {
- name: `${account.name} <${account.email}>`,
- value: {account, count: 1},
- });
-
- suggestion = provider.makeSuggestionItem({account: {}, count: 1});
- assert.deepEqual(suggestion, {
- name: 'Name of user not set',
- value: {account: {}, count: 1},
- });
-
- provider.config = {
- ...createServerInfo(),
- user: {
- anonymous_coward_name: 'Anonymous Coward Name',
- },
- };
-
- suggestion = provider.makeSuggestionItem({account: {}, count: 1});
- assert.deepEqual(suggestion, {
- name: 'Anonymous Coward Name',
- value: {account: {}, count: 1},
- });
-
- account = makeAccount('OOO');
-
- suggestion = provider.makeSuggestionItem({account, count: 1});
- assert.deepEqual(suggestion, {
- name: `${account.name} <${account.email}> (OOO)`,
- value: {account, count: 1},
- });
-
- suggestion = provider.makeSuggestionItem(account);
- assert.deepEqual(suggestion, {
- name: `${account.name} <${account.email}> (OOO)`,
- value: {account, count: 1},
- });
-
- account3.email = undefined;
-
- suggestion = provider.makeSuggestionItem(account3);
- assert.deepEqual(suggestion, {
- name: account3.name,
- value: {account: account3, count: 1},
- });
- });
-
- test('getSuggestions', async () => {
- const reviewers = await provider.getSuggestions('');
-
- // Default is no filtering.
- assert.equal(reviewers.length, 6);
- assert.deepEqual(reviewers, [
- redundantSuggestion1,
- redundantSuggestion2,
- redundantSuggestion3,
- suggestion1,
- suggestion2,
- suggestion3,
- ]);
- });
-
- test('getSuggestions short circuits when logged out', () => {
- provider.loggedIn = false;
- return provider.getSuggestions('').then(() => {
- assert.isFalse(getChangeSuggestedReviewersStub.called);
- provider.loggedIn = true;
- return provider.getSuggestions('').then(() => {
- assert.isTrue(getChangeSuggestedReviewersStub.called);
- });
- });
- });
- });
-
- test('getChangeSuggestedReviewers is used', async () => {
- const suggestReviewerStub = stubRestApi(
- 'getChangeSuggestedReviewers'
- ).returns(Promise.resolve([]));
- const suggestAccountStub = stubRestApi('getSuggestedAccounts').returns(
- Promise.resolve([])
- );
-
- await provider.getSuggestions('');
- assert.isTrue(suggestReviewerStub.calledOnce);
- assert.isTrue(suggestReviewerStub.calledWith(42 as NumericChangeId, ''));
- assert.isFalse(suggestAccountStub.called);
- });
+ assert.sameDeepMembers(reviewers, [suggestion1, suggestion2, suggestion3]);
});
- suite('allowAnyUser set to true', () => {
- setup(async () => {
- provider = GrReviewerSuggestionsProvider.create(
- getAppContext().restApiService,
- change._number,
- SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
- );
- await provider.init();
+ test('getSuggestions short circuits when logged out', async () => {
+ await provider.getSuggestions('');
+ assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+
+ // not logged in
+ provider = new GrReviewerSuggestionsProvider(
+ getAppContext().restApiService,
+ ReviewerState.REVIEWER,
+ createServerInfo(),
+ false,
+ change._number
+ );
+
+ await provider.getSuggestions('');
+
+ // no additional call is made
+ assert.isTrue(getChangeSuggestedReviewersStub.calledOnce);
+ });
+
+ test('only returns REVIEWER suggestions shared by all changes', async () => {
+ getChangeSuggestedReviewersStub
+ .onSecondCall()
+ .resolves([suggestion2, suggestion3]);
+ provider = new GrReviewerSuggestionsProvider(
+ getAppContext().restApiService,
+ ReviewerState.REVIEWER,
+ createServerInfo(),
+ true,
+ ...[change._number, 43 as NumericChangeId]
+ );
+
+ // suggestion1 is excluded because it is not returned for the second
+ // change.
+ assert.sameDeepMembers(await provider.getSuggestions('s'), [
+ suggestion2,
+ suggestion3,
+ ]);
+ });
+
+ test('only returns CC suggestions shared by all changes', async () => {
+ getChangeSuggestedCCsStub
+ .onSecondCall()
+ .resolves([suggestion2, suggestion3]);
+ provider = new GrReviewerSuggestionsProvider(
+ getAppContext().restApiService,
+ ReviewerState.CC,
+ createServerInfo(),
+ true,
+ ...[change._number, 43 as NumericChangeId]
+ );
+
+ // suggestion1 is excluded because it is not returned for the second
+ // change.
+ assert.sameDeepMembers(await provider.getSuggestions('s'), [
+ suggestion2,
+ suggestion3,
+ ]);
+ });
+
+ test('makeSuggestionItem formats account or group accordingly', () => {
+ let account = createAccountDetailWithIdNameAndEmail(1);
+ const account3 = createAccountDetailWithIdNameAndEmail(2);
+ let suggestion = provider.makeSuggestionItem({account, count: 1});
+ assert.deepEqual(suggestion, {
+ name: `${account.name} <${account.email}>`,
+ value: {account, count: 1},
});
- test('getSuggestedAccounts is used', async () => {
- const suggestReviewerStub = stubRestApi(
- 'getChangeSuggestedReviewers'
- ).returns(Promise.resolve([]));
- const suggestAccountStub = stubRestApi('getSuggestedAccounts').returns(
- Promise.resolve([])
- );
+ const group = {name: 'test' as GroupName, id: '5' as GroupId};
+ suggestion = provider.makeSuggestionItem({group, count: 1});
+ assert.deepEqual(suggestion, {
+ name: `${group.name} (group)`,
+ value: {group, count: 1},
+ });
- await provider.getSuggestions('');
- assert.isFalse(suggestReviewerStub.called);
- assert.isTrue(suggestAccountStub.calledOnce);
- assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
+ suggestion = provider.makeSuggestionItem(account);
+ assert.deepEqual(suggestion, {
+ name: `${account.name} <${account.email}>`,
+ value: {account, count: 1},
+ });
+
+ suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+ assert.deepEqual(suggestion, {
+ name: 'Name of user not set',
+ value: {account: {}, count: 1},
+ });
+
+ provider = new GrReviewerSuggestionsProvider(
+ getAppContext().restApiService,
+ ReviewerState.REVIEWER,
+ {
+ ...createServerInfo(),
+ user: {
+ anonymous_coward_name: 'Anonymous Coward Name',
+ },
+ },
+ true,
+ change._number
+ );
+
+ suggestion = provider.makeSuggestionItem({account: {}, count: 1});
+ assert.deepEqual(suggestion, {
+ name: 'Anonymous Coward Name',
+ value: {account: {}, count: 1},
+ });
+
+ account = {...createAccountDetailWithIdNameAndEmail(3), status: 'OOO'};
+
+ suggestion = provider.makeSuggestionItem({account, count: 1});
+ assert.deepEqual(suggestion, {
+ name: `${account.name} <${account.email}> (OOO)`,
+ value: {account, count: 1},
+ });
+
+ suggestion = provider.makeSuggestionItem(account);
+ assert.deepEqual(suggestion, {
+ name: `${account.name} <${account.email}> (OOO)`,
+ value: {account, count: 1},
+ });
+
+ account3.email = undefined;
+
+ suggestion = provider.makeSuggestionItem(account3);
+ assert.deepEqual(suggestion, {
+ name: account3.name,
+ value: {account: account3, count: 1},
});
});
});
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());
},
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 43fd6f5..981bcae 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -147,12 +147,18 @@
export const isFalse = (b: boolean) => b === false;
-// An equivalent to Promise.allSettled from ES2020.
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
-// TODO: Migrate our tooling to ES2020 and remove this method.
export type PromiseResult<T> =
| {status: 'fulfilled'; value: T}
| {status: 'rejected'; reason: string};
+export function isFulfilled<T>(
+ promiseResult?: PromiseResult<T>
+): promiseResult is PromiseResult<T> & {status: 'fulfilled'} {
+ return promiseResult?.status === 'fulfilled';
+}
+
+// An equivalent to Promise.allSettled from ES2020.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled
+// TODO: Migrate our tooling to ES2020 and remove this method.
export function allSettled<T>(
promises: Promise<T>[]
): Promise<PromiseResult<T>[]> {
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 9e3bc74..6ccf770 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -156,3 +156,22 @@
export function unique<T>(item: T, index: number, array: T[]) {
return array.indexOf(item) === index;
}
+
+/**
+ * Returns the elements that are present in every sub-array. If a compareBy
+ * predicate is passed in, it will be used instead of strict equality.
+ */
+export function intersection<T>(
+ arrays: T[][],
+ compareBy: (t: T, u: T) => boolean = (t, u) => t === u
+): T[] {
+ // Array.prototype.reduce needs either an initialValue or a non-empty array.
+ // Since there is no good initialValue for intersecting (∅ ∩ X = ∅), the
+ // empty array must be checked separately.
+ if (arrays.length === 0) {
+ return [];
+ }
+ return arrays.reduce((result, array) =>
+ result.filter(t => array.find(u => compareBy(t, u)))
+ );
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.ts b/polygerrit-ui/app/utils/common-util_test.ts
index 4156729..8cc523a 100644
--- a/polygerrit-ui/app/utils/common-util_test.ts
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -16,7 +16,12 @@
*/
import '../test/common-test-setup-karma';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
+import {
+ hasOwnProperty,
+ areSetsEqual,
+ containsAll,
+ intersection,
+} from './common-util';
suite('common-util tests', () => {
suite('hasOwnProperty', () => {
@@ -68,4 +73,28 @@
assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
});
+
+ test('intersections', () => {
+ assert.sameDeepMembers(intersection([]), []);
+ assert.sameDeepMembers(intersection([[1, 2, 3]]), [1, 2, 3]);
+ assert.sameDeepMembers(
+ intersection([
+ [1, 2, 3],
+ [2, 3, 4],
+ [5, 3, 2],
+ ]),
+ [2, 3]
+ );
+
+ const foo1 = {value: 5};
+ const foo2 = {value: 5};
+
+ // these foo's will fail strict equality with each other, but a comparator
+ // can make them intersect.
+ assert.sameDeepMembers(intersection([[foo1], [foo2]]), []);
+ assert.sameDeepMembers(
+ intersection([[foo1], [foo2]], (a, b) => a.value === b.value),
+ [foo1]
+ );
+ });
});