blob: f7a2531df6a9089771279346b51ef412f2ca8f2a [file] [log] [blame]
/**
* @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
);
});
});
});