| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {fixture, html, assert} from '@open-wc/testing'; |
| import {IronDropdownElement} from '@polymer/iron-dropdown'; |
| import {SinonStubbedMember} from 'sinon'; |
| import { |
| BulkActionsModel, |
| bulkActionsModelToken, |
| } from '../../../models/bulk-actions/bulk-actions-model'; |
| import {wrapInProvider} from '../../../models/di-provider-element'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; |
| import '../../../test/common-test-setup'; |
| import {createChange} from '../../../test/test-data-generators'; |
| import { |
| MockPromise, |
| mockPromise, |
| query, |
| queryAll, |
| queryAndAssert, |
| stubReporting, |
| stubRestApi, |
| waitEventLoop, |
| waitUntil, |
| waitUntilCalled, |
| waitUntilObserved, |
| } from '../../../test/test-utils'; |
| import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common'; |
| import {EventType} from '../../../types/events'; |
| 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; |
| let reportingStub: SinonStubbedMember<ReportingService['reportInteraction']>; |
| |
| setup(() => { |
| reportingStub = stubReporting('reportInteraction'); |
| }); |
| |
| 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 () => { |
| assert.shadowDom.equal( |
| element, |
| /* HTML */ ` |
| <gr-button |
| id="start-flow" |
| flatten="" |
| down-arrow="" |
| 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('hashtag flow', () => { |
| const changes: ChangeInfo[] = [ |
| { |
| ...createChange(), |
| _number: 1 as NumericChangeId, |
| subject: 'Subject 1', |
| hashtags: ['hashtag1' as Hashtag, 'sharedHashtag' as Hashtag], |
| }, |
| { |
| ...createChange(), |
| _number: 2 as NumericChangeId, |
| subject: 'Subject 2', |
| hashtags: ['hashtag2' as Hashtag, 'sharedHashtag' as Hashtag], |
| }, |
| { |
| ...createChange(), |
| _number: 3 as NumericChangeId, |
| subject: 'Subject 3', |
| hashtags: ['sharedHashtag' as Hashtag], |
| }, |
| ]; |
| let setChangeHashtagPromises: MockPromise<Hashtag[]>[]; |
| let setChangeHashtagStub: sinon.SinonStub; |
| |
| async function resolvePromises(newHashtags: Hashtag[]) { |
| setChangeHashtagPromises[0].resolve([ |
| ...(changes[0].hashtags ?? []), |
| ...newHashtags, |
| ]); |
| setChangeHashtagPromises[1].resolve([ |
| ...(changes[1].hashtags ?? []), |
| ...newHashtags, |
| ]); |
| setChangeHashtagPromises[2].resolve([ |
| ...(changes[2].hashtags ?? []), |
| ...newHashtags, |
| ]); |
| await element.updateComplete; |
| } |
| |
| async function rejectPromises() { |
| setChangeHashtagPromises[0].reject(new Error('error')); |
| setChangeHashtagPromises[1].reject(new Error('error')); |
| setChangeHashtagPromises[2].reject(new Error('error')); |
| await element.updateComplete; |
| } |
| |
| setup(async () => { |
| stubRestApi('getDetailedChangesWithActions').resolves(changes); |
| setChangeHashtagPromises = []; |
| setChangeHashtagStub = stubRestApi('setChangeHashtag'); |
| for (let i = 0; i < changes.length; i++) { |
| const promise = mockPromise<Hashtag[]>(); |
| setChangeHashtagPromises.push(promise); |
| setChangeHashtagStub |
| .withArgs(changes[i]._number, sinon.match.any) |
| .returns(promise); |
| } |
| 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')!; |
| |
| // select changes |
| await selectChange(changes[0]); |
| await selectChange(changes[1]); |
| await selectChange(changes[2]); |
| await waitUntilObserved(model.selectedChanges$, s => s.length === 3); |
| await element.updateComplete; |
| |
| // open flow |
| queryAndAssert<GrButton>(element, 'gr-button#start-flow').click(); |
| await element.updateComplete; |
| await waitEventLoop(); |
| }); |
| |
| test('renders hashtags flow', () => { |
| assert.shadowDom.equal( |
| element, |
| /* HTML */ ` |
| <gr-button |
| id="start-flow" |
| flatten="" |
| down-arrow="" |
| 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"> |
| <button |
| role="listbox" |
| aria-label="hashtag1 selection" |
| class="chip" |
| > |
| hashtag1 |
| </button> |
| <button |
| role="listbox" |
| aria-label="sharedHashtag selection" |
| class="chip" |
| > |
| sharedHashtag |
| </button> |
| <button |
| role="listbox" |
| aria-label="hashtag2 selection" |
| class="chip" |
| > |
| hashtag2 |
| </button> |
| </div> |
| <gr-autocomplete |
| placeholder="Type hashtag name to create or filter hashtags" |
| show-blue-focus-border="" |
| ></gr-autocomplete> |
| <div class="footer"> |
| <div class="loadingOrError" role="progressbar"></div> |
| <div class="buttons"> |
| <gr-button |
| id="add-hashtag-button" |
| flatten="" |
| aria-disabled="true" |
| disabled="" |
| role="button" |
| tabindex="-1" |
| >Add Hashtag</gr-button |
| > |
| </div> |
| </div> |
| </div> |
| </iron-dropdown> |
| `, |
| { |
| // iron-dropdown sizing seems to vary between local & CI |
| ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}], |
| } |
| ); |
| }); |
| |
| test('add hashtag from selected change', async () => { |
| const alertStub = sinon.stub(); |
| element.addEventListener(EventType.SHOW_ALERT, alertStub); |
| // selects "hashtag1" |
| queryAll<HTMLButtonElement>(element, 'button.chip')[0].click(); |
| await element.updateComplete; |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await resolvePromises(['hashtag1' as Hashtag]); |
| await element.updateComplete; |
| |
| assert.isTrue(setChangeHashtagStub.calledThrice); |
| assert.deepEqual(setChangeHashtagStub.firstCall.args, [ |
| changes[0]._number, |
| {add: ['hashtag1']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.secondCall.args, [ |
| changes[1]._number, |
| {add: ['hashtag1']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.thirdCall.args, [ |
| changes[2]._number, |
| {add: ['hashtag1']}, |
| ]); |
| await waitUntilCalled(alertStub, 'alertStub'); |
| assert.deepEqual(alertStub.lastCall.args[0].detail, { |
| message: '3 Changes added to hashtag1', |
| showDismiss: true, |
| }); |
| assert.deepEqual(reportingStub.lastCall.args[1], { |
| type: 'add-hashtag', |
| selectedChangeCount: 3, |
| hashtagsApplied: 1, |
| }); |
| assert.isTrue( |
| queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened |
| ); |
| }); |
| |
| test('shows error when add hashtag fails', async () => { |
| // selects "hashtag1" |
| queryAll<HTMLButtonElement>(element, 'button.chip')[0].click(); |
| await element.updateComplete; |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await rejectPromises(); |
| await element.updateComplete; |
| await waitUntil(() => query(element, '.error') !== undefined); |
| |
| assert.equal( |
| queryAndAssert(element, '.error').textContent, |
| 'Failed to add' |
| ); |
| assert.equal( |
| queryAndAssert(element, 'gr-button#cancel-button').textContent, |
| 'Cancel' |
| ); |
| assert.isUndefined(query(element, '.loadingText')); |
| }); |
| |
| test('add multiple hashtag from selected change', async () => { |
| const alertStub = sinon.stub(); |
| element.addEventListener(EventType.SHOW_ALERT, alertStub); |
| // selects "hashtag1" |
| queryAll<HTMLButtonElement>(element, 'button.chip')[0].click(); |
| await element.updateComplete; |
| |
| // selects "hashtag2" |
| queryAll<HTMLButtonElement>(element, 'button.chip')[2].click(); |
| await element.updateComplete; |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await resolvePromises(['hashtag1' as Hashtag, 'hashtag2' as Hashtag]); |
| await element.updateComplete; |
| |
| assert.isTrue(setChangeHashtagStub.calledThrice); |
| assert.deepEqual(setChangeHashtagStub.firstCall.args, [ |
| changes[0]._number, |
| {add: ['hashtag1', 'hashtag2']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.secondCall.args, [ |
| changes[1]._number, |
| {add: ['hashtag1', 'hashtag2']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.thirdCall.args, [ |
| changes[2]._number, |
| {add: ['hashtag1', 'hashtag2']}, |
| ]); |
| |
| await waitUntilCalled(alertStub, 'alertStub'); |
| assert.deepEqual(alertStub.lastCall.args[0].detail, { |
| message: '2 hashtags added to changes', |
| showDismiss: true, |
| }); |
| assert.deepEqual(reportingStub.lastCall.args[1], { |
| type: 'add-hashtag', |
| selectedChangeCount: 3, |
| hashtagsApplied: 2, |
| }); |
| }); |
| |
| test('add existing hashtag not on selected changes', async () => { |
| const alertStub = sinon.stub(); |
| element.addEventListener(EventType.SHOW_ALERT, alertStub); |
| |
| const getHashtagsStub = stubRestApi( |
| 'getChangesWithSimilarHashtag' |
| ).resolves([{...createChange(), hashtags: ['foo' as Hashtag]}]); |
| const autocomplete = queryAndAssert<GrAutocomplete>( |
| element, |
| 'gr-autocomplete' |
| ); |
| |
| autocomplete.setFocus(true); |
| autocomplete.text = 'foo'; |
| await element.updateComplete; |
| await waitUntilCalled(getHashtagsStub, 'getHashtagsStub'); |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await resolvePromises(['foo' as Hashtag]); |
| |
| assert.isTrue(setChangeHashtagStub.calledThrice); |
| assert.deepEqual(setChangeHashtagStub.firstCall.args, [ |
| changes[0]._number, |
| {add: ['foo']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.secondCall.args, [ |
| changes[1]._number, |
| {add: ['foo']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.thirdCall.args, [ |
| changes[2]._number, |
| {add: ['foo']}, |
| ]); |
| |
| await waitUntilCalled(alertStub, 'alertStub'); |
| assert.deepEqual(alertStub.lastCall.args[0].detail, { |
| message: '3 Changes added to foo', |
| showDismiss: true, |
| }); |
| assert.deepEqual(reportingStub.lastCall.args[1], { |
| type: 'add-hashtag', |
| selectedChangeCount: 3, |
| hashtagsApplied: 1, |
| }); |
| assert.isTrue( |
| queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened |
| ); |
| }); |
| |
| test('add new hashtag', async () => { |
| const alertStub = sinon.stub(); |
| element.addEventListener(EventType.SHOW_ALERT, alertStub); |
| |
| const getHashtagsStub = stubRestApi( |
| 'getChangesWithSimilarHashtag' |
| ).resolves([]); |
| const autocomplete = queryAndAssert<GrAutocomplete>( |
| element, |
| 'gr-autocomplete' |
| ); |
| autocomplete.setFocus(true); |
| autocomplete.text = 'foo'; |
| await element.updateComplete; |
| await waitUntilCalled(getHashtagsStub, 'getHashtagsStub'); |
| assert.isFalse( |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled |
| ); |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await resolvePromises(['foo' as Hashtag]); |
| await waitUntilObserved(model.selectedChanges$, selected => |
| selected.every(change => change.hashtags?.includes('foo' as Hashtag)) |
| ); |
| await element.updateComplete; |
| |
| assert.isTrue(setChangeHashtagStub.calledThrice); |
| assert.deepEqual(setChangeHashtagStub.firstCall.args, [ |
| changes[0]._number, |
| {add: ['foo']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.secondCall.args, [ |
| changes[1]._number, |
| {add: ['foo']}, |
| ]); |
| assert.deepEqual(setChangeHashtagStub.thirdCall.args, [ |
| changes[2]._number, |
| {add: ['foo']}, |
| ]); |
| |
| await waitUntilCalled(alertStub, 'alertStub'); |
| assert.deepEqual(alertStub.lastCall.args[0].detail, { |
| message: '3 Changes added to foo', |
| showDismiss: true, |
| }); |
| assert.deepEqual(reportingStub.lastCall.args[1], { |
| type: 'add-hashtag', |
| selectedChangeCount: 3, |
| hashtagsApplied: 1, |
| }); |
| assert.isTrue( |
| queryAndAssert<IronDropdownElement>(element, 'iron-dropdown').opened |
| ); |
| assert.equal( |
| queryAll<HTMLButtonElement>(element, 'button.chip')[2].innerText, |
| 'foo' |
| ); |
| }); |
| |
| test('shows error when add hashtag fails', async () => { |
| const getHashtagsStub = stubRestApi( |
| 'getChangesWithSimilarHashtag' |
| ).resolves([]); |
| const autocomplete = queryAndAssert<GrAutocomplete>( |
| element, |
| 'gr-autocomplete' |
| ); |
| autocomplete.setFocus(true); |
| autocomplete.text = 'foo'; |
| await element.updateComplete; |
| await waitUntilCalled(getHashtagsStub, 'getHashtagsStub'); |
| assert.isFalse( |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled |
| ); |
| |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').click(); |
| await element.updateComplete; |
| |
| assert.equal( |
| queryAndAssert(element, '.loadingText').textContent, |
| 'Adding hashtag...' |
| ); |
| |
| await rejectPromises(); |
| await element.updateComplete; |
| await waitUntil(() => query(element, '.error') !== undefined); |
| |
| assert.equal( |
| queryAndAssert(element, '.error').textContent, |
| 'Failed to add' |
| ); |
| assert.equal( |
| queryAndAssert(element, 'gr-button#cancel-button').textContent, |
| 'Cancel' |
| ); |
| assert.isUndefined(query(element, '.loadingText')); |
| }); |
| |
| test('cannot add existing hashtag already on selected changes', async () => { |
| const alertStub = sinon.stub(); |
| element.addEventListener(EventType.SHOW_ALERT, alertStub); |
| // selects "sharedHashtag" |
| queryAll<HTMLButtonElement>(element, 'button.chip')[1].click(); |
| await element.updateComplete; |
| |
| assert.isTrue( |
| queryAndAssert<GrButton>(element, '#add-hashtag-button').disabled |
| ); |
| }); |
| }); |
| }); |