Commit ff19ae6a authored by Thong Kuah's avatar Thong Kuah

Merge branch '221063-dismiss-user-notifcation-dot' into 'master'

Dismiss user notification dot upon acknowledgement

See merge request gitlab-org/gitlab!34397
parents c66f85ce 161e7b76
...@@ -18,17 +18,21 @@ export default class PersistentUserCallout { ...@@ -18,17 +18,21 @@ export default class PersistentUserCallout {
init() { init() {
const closeButton = this.container.querySelector('.js-close'); const closeButton = this.container.querySelector('.js-close');
const followLink = this.container.querySelector('.js-follow-link');
if (!closeButton) { if (closeButton) {
return; this.handleCloseButtonCallout(closeButton);
} else if (followLink) {
this.handleFollowLinkCallout(followLink);
}
} }
handleCloseButtonCallout(closeButton) {
closeButton.addEventListener('click', event => this.dismiss(event)); closeButton.addEventListener('click', event => this.dismiss(event));
if (this.deferLinks) { if (this.deferLinks) {
this.container.addEventListener('click', event => { this.container.addEventListener('click', event => {
const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS); const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS);
if (isDeferredLink) { if (isDeferredLink) {
const { href, target } = event.target; const { href, target } = event.target;
...@@ -38,6 +42,10 @@ export default class PersistentUserCallout { ...@@ -38,6 +42,10 @@ export default class PersistentUserCallout {
} }
} }
handleFollowLinkCallout(followLink) {
followLink.addEventListener('click', event => this.registerCalloutWithLink(event));
}
dismiss(event, deferredLinkOptions = null) { dismiss(event, deferredLinkOptions = null) {
event.preventDefault(); event.preventDefault();
...@@ -58,6 +66,27 @@ export default class PersistentUserCallout { ...@@ -58,6 +66,27 @@ export default class PersistentUserCallout {
}); });
} }
registerCalloutWithLink(event) {
event.preventDefault();
const { href } = event.currentTarget;
axios
.post(this.dismissEndpoint, {
feature_name: this.featureId,
})
.then(() => {
window.location.assign(href);
})
.catch(() => {
Flash(
__(
'An error occurred while acknowledging the notification. Refresh the page and try again.',
),
);
});
}
static factory(container, options) { static factory(container, options) {
if (!container) { if (!container) {
return undefined; return undefined;
......
...@@ -4,6 +4,7 @@ const PERSISTENT_USER_CALLOUTS = [ ...@@ -4,6 +4,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-recovery-settings-callout', '.js-recovery-settings-callout',
'.js-users-over-license-callout', '.js-users-over-license-callout',
'.js-admin-licensed-user-count-threshold', '.js-admin-licensed-user-count-threshold',
'.js-buy-pipeline-minutes-notification-callout',
]; ];
const initCallouts = () => { const initCallouts = () => {
......
...@@ -3,6 +3,8 @@ module EE ...@@ -3,6 +3,8 @@ module EE
module RunnersHelper module RunnersHelper
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
BUY_PIPELINE_MINUTES_NOTIFICATION_DOT = 'buy_pipeline_minutes_notification_dot'
def show_buy_pipeline_minutes?(project, namespace) def show_buy_pipeline_minutes?(project, namespace)
return false unless experiment_enabled?(:ci_notification_dot) || experiment_enabled?(:buy_ci_minutes_version_a) return false unless experiment_enabled?(:ci_notification_dot) || experiment_enabled?(:buy_ci_minutes_version_a)
...@@ -11,12 +13,26 @@ module EE ...@@ -11,12 +13,26 @@ module EE
def show_pipeline_minutes_notification_dot?(project, namespace) def show_pipeline_minutes_notification_dot?(project, namespace)
return false unless experiment_enabled?(:ci_notification_dot) return false unless experiment_enabled?(:ci_notification_dot)
return false if notification_dot_acknowledged?
show_out_of_pipeline_minutes_notification?(project, namespace)
end
def show_buy_pipeline_with_subtext?(project, namespace)
return false unless experiment_enabled?(:ci_notification_dot)
return false unless notification_dot_acknowledged?
show_out_of_pipeline_minutes_notification?(project, namespace) show_out_of_pipeline_minutes_notification?(project, namespace)
end end
private private
def notification_dot_acknowledged?
strong_memoize(:notification_dot_acknowledged) do
user_dismissed?(BUY_PIPELINE_MINUTES_NOTIFICATION_DOT)
end
end
def show_out_of_pipeline_minutes_notification?(project, namespace) def show_out_of_pipeline_minutes_notification?(project, namespace)
strong_memoize(:show_out_of_pipeline_minutes_notification) do strong_memoize(:show_out_of_pipeline_minutes_notification) do
next unless project&.persisted? || namespace&.persisted? next unless project&.persisted? || namespace&.persisted?
......
...@@ -19,7 +19,8 @@ module EE ...@@ -19,7 +19,8 @@ module EE
account_recovery_regular_check: 12, account_recovery_regular_check: 12,
users_over_license_banner: 16, users_over_license_banner: 16,
standalone_vulnerabilities_introduction_banner: 17, standalone_vulnerabilities_introduction_banner: 17,
active_user_count_threshold: 18 active_user_count_threshold: 18,
buy_pipeline_minutes_notification_dot: 19
) )
end end
end end
......
...@@ -2,17 +2,30 @@ ...@@ -2,17 +2,30 @@
- link_text = s_("CurrentUser|Buy Pipeline minutes") - link_text = s_("CurrentUser|Buy Pipeline minutes")
- link_emoji = emoji_icon('clock9', 'aria-hidden': true) - link_emoji = emoji_icon('clock9', 'aria-hidden': true)
- link_class = 'ci-minutes-emoji js-buy-pipeline-minutes-link'
- data_attributes = { 'track-event': 'click_buy_ci_minutes', 'track-label': current_user.namespace.actual_plan_name, 'track-property': 'user_dropdown' }
- path = profile_usage_quotas_path
%li - content_for :buy_pipeline_with_subtext do
= link_to profile_usage_quotas_path,
class: 'ci-minutes-emoji js-buy-pipeline-minutes-link',
data: { 'track-event': 'click_buy_ci_minutes', 'track-label': current_user.namespace.actual_plan_name, 'track-property': 'user_dropdown' } do
- if show_pipeline_minutes_notification_dot?(project, namespace)
.gl-pb-2 .gl-pb-2
= link_text = link_text
= link_emoji = link_emoji
%span.small.gl-pb-3.gl-text-orange-800 %span.small.gl-pb-3.gl-text-orange-800
= s_("CurrentUser|One of your groups is running out") = s_("CurrentUser|One of your groups is running out")
- else
- if show_pipeline_minutes_notification_dot?(project, namespace)
- link_class << ' js-follow-link'
%li.js-buy-pipeline-minutes-notification-callout{ data: { feature_id: RunnersHelper::BUY_PIPELINE_MINUTES_NOTIFICATION_DOT,
dismiss_endpoint: user_callouts_path } }
= link_to path, class: link_class, data: data_attributes do
= yield :buy_pipeline_with_subtext
- elsif show_buy_pipeline_with_subtext?(project, namespace)
%li
= link_to path, class: link_class, data: data_attributes do
= yield :buy_pipeline_with_subtext
- else
%li
= link_to path, class: link_class, data: data_attributes do
= link_text = link_text
= link_emoji = link_emoji
...@@ -4,14 +4,14 @@ require "spec_helper" ...@@ -4,14 +4,14 @@ require "spec_helper"
RSpec.describe EE::RunnersHelper do RSpec.describe EE::RunnersHelper do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:namespace) { create(:namespace, owner: user) }
let_it_be(:project) { create(:project, namespace: namespace) }
before do before do
allow(helper).to receive(:current_user).and_return(user) allow(helper).to receive(:current_user).and_return(user)
end end
shared_examples_for 'minutes notification' do shared_examples_for 'minutes notification' do
let_it_be(:namespace) { create(:namespace, owner: user) }
let_it_be(:project) { create(:project, namespace: namespace) }
let(:show_warning) { true } let(:show_warning) { true }
let(:context_level) { project } let(:context_level) { project }
let(:threshold) { double('Ci::Minutes::Notification', show?: show_warning) } let(:threshold) { double('Ci::Minutes::Notification', show?: show_warning) }
...@@ -109,10 +109,52 @@ RSpec.describe EE::RunnersHelper do ...@@ -109,10 +109,52 @@ RSpec.describe EE::RunnersHelper do
describe '.show_pipeline_minutes_notification_dot?' do describe '.show_pipeline_minutes_notification_dot?' do
subject { helper.show_pipeline_minutes_notification_dot?(project, namespace) } subject { helper.show_pipeline_minutes_notification_dot?(project, namespace) }
it_behaves_like 'minutes notification' do
before do before do
allow(helper).to receive(:experiment_enabled?).with(:ci_notification_dot).and_return(experiment_status) allow(helper).to receive(:experiment_enabled?).with(:ci_notification_dot).and_return(experiment_status)
end end
it_behaves_like 'minutes notification'
context 'when the notification dot has been acknowledged' do
before do
create(:user_callout, user: user, feature_name: described_class::BUY_PIPELINE_MINUTES_NOTIFICATION_DOT)
expect(helper).not_to receive(:show_out_of_pipeline_minutes_notification?)
end
it { is_expected.to be_falsy }
end
context 'when the notification dot has not been acknowledged' do
before do
expect(helper).to receive(:show_out_of_pipeline_minutes_notification?).and_return(true)
end
it { is_expected.to be_truthy }
end
end
describe '.show_buy_pipeline_with_subtext?' do
subject { helper.show_buy_pipeline_with_subtext?(project, namespace) }
before do
allow(helper).to receive(:experiment_enabled?).with(:ci_notification_dot).and_return(experiment_status)
end
context 'when the notification dot has not been acknowledged' do
before do
expect(helper).not_to receive(:show_out_of_pipeline_minutes_notification?)
end
it { is_expected.to be_falsey }
end
context 'when the notification dot has been acknowledged' do
before do
create(:user_callout, user: user, feature_name: described_class::BUY_PIPELINE_MINUTES_NOTIFICATION_DOT)
expect(helper).to receive(:show_out_of_pipeline_minutes_notification?).and_return(true)
end
it { is_expected.to be_truthy }
end end
end end
end end
......
...@@ -8,19 +8,21 @@ RSpec.describe 'layouts/header/_current_user_dropdown' do ...@@ -8,19 +8,21 @@ RSpec.describe 'layouts/header/_current_user_dropdown' do
describe 'Buy Pipeline Minutes link in user dropdown' do describe 'Buy Pipeline Minutes link in user dropdown' do
let(:need_minutes) { true } let(:need_minutes) { true }
let(:show_notification_dot) { false } let(:show_notification_dot) { false }
let(:show_subtext) { false }
before do before do
allow(view).to receive(:current_user).and_return(user) allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:show_upgrade_link?).and_return(false) allow(view).to receive(:show_upgrade_link?).and_return(false)
allow(view).to receive(:show_buy_pipeline_minutes?).and_return(need_minutes) allow(view).to receive(:show_buy_pipeline_minutes?).and_return(need_minutes)
allow(view).to receive(:show_pipeline_minutes_notification_dot?).and_return(show_notification_dot) allow(view).to receive(:show_pipeline_minutes_notification_dot?).and_return(show_notification_dot)
allow(view).to receive(:show_buy_pipeline_with_subtext?).and_return(show_subtext)
render render
end end
subject { rendered } subject { rendered }
context 'when pipeline minutes need bought' do context 'when pipeline minutes need bought without notification dot' do
it 'has "Buy Pipeline minutes" link with correct data properties', :aggregate_failures do it 'has "Buy Pipeline minutes" link with correct data properties', :aggregate_failures do
expect(subject).to have_selector('[data-track-event="click_buy_ci_minutes"]') expect(subject).to have_selector('[data-track-event="click_buy_ci_minutes"]')
expect(subject).to have_selector("[data-track-label='#{user.namespace.actual_plan_name}']") expect(subject).to have_selector("[data-track-label='#{user.namespace.actual_plan_name}']")
...@@ -36,6 +38,21 @@ RSpec.describe 'layouts/header/_current_user_dropdown' do ...@@ -36,6 +38,21 @@ RSpec.describe 'layouts/header/_current_user_dropdown' do
it 'has "Buy Pipeline minutes" link with correct text', :aggregate_failures do it 'has "Buy Pipeline minutes" link with correct text', :aggregate_failures do
expect(subject).to have_link('Buy Pipeline minutes') expect(subject).to have_link('Buy Pipeline minutes')
expect(subject).to have_content('One of your groups is running out') expect(subject).to have_content('One of your groups is running out')
expect(subject).to have_selector('.js-follow-link')
expect(subject).to have_selector("[data-feature-id='#{RunnersHelper::BUY_PIPELINE_MINUTES_NOTIFICATION_DOT}']")
expect(subject).to have_selector("[data-dismiss-endpoint='#{user_callouts_path}']")
end
end
context 'when pipeline minutes need bought and notification dot has been acknowledged' do
let(:show_subtext) { true }
it 'has "Buy Pipeline minutes" link with correct text', :aggregate_failures do
expect(subject).to have_link('Buy Pipeline minutes')
expect(subject).to have_content('One of your groups is running out')
expect(subject).not_to have_selector('.js-follow-link')
expect(subject).not_to have_selector("[data-feature-id='#{RunnersHelper::BUY_PIPELINE_MINUTES_NOTIFICATION_DOT}']")
expect(subject).not_to have_selector("[data-dismiss-endpoint='#{user_callouts_path}']")
end end
end end
......
...@@ -2257,6 +2257,9 @@ msgstr "" ...@@ -2257,6 +2257,9 @@ msgstr ""
msgid "An error occurred when updating the issue weight" msgid "An error occurred when updating the issue weight"
msgstr "" msgstr ""
msgid "An error occurred while acknowledging the notification. Refresh the page and try again."
msgstr ""
msgid "An error occurred while adding formatted title for epic" msgid "An error occurred while adding formatted title for epic"
msgstr "" msgstr ""
......
...@@ -43,6 +43,23 @@ describe('PersistentUserCallout', () => { ...@@ -43,6 +43,23 @@ describe('PersistentUserCallout', () => {
return fixture; return fixture;
} }
function createFollowLinkFixture() {
const fixture = document.createElement('div');
fixture.innerHTML = `
<ul>
<li
class="container"
data-dismiss-endpoint="${dismissEndpoint}"
data-feature-id="${featureName}"
>
<a class="js-follow-link" href="/somewhere-pleasant">A Link</a>
</li>
</ul>
`;
return fixture;
}
describe('dismiss', () => { describe('dismiss', () => {
let button; let button;
let mockAxios; let mockAxios;
...@@ -144,6 +161,55 @@ describe('PersistentUserCallout', () => { ...@@ -144,6 +161,55 @@ describe('PersistentUserCallout', () => {
}); });
}); });
describe('follow links', () => {
let link;
let mockAxios;
let persistentUserCallout;
beforeEach(() => {
const fixture = createFollowLinkFixture();
const container = fixture.querySelector('.container');
link = fixture.querySelector('.js-follow-link');
mockAxios = new MockAdapter(axios);
persistentUserCallout = new PersistentUserCallout(container);
jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {});
delete window.location;
window.location = { assign: jest.fn() };
});
afterEach(() => {
mockAxios.restore();
});
it('uses a link to trigger callout and defers following until callout is finished', () => {
const { href } = link;
mockAxios.onPost(dismissEndpoint).replyOnce(200);
link.click();
return waitForPromises().then(() => {
expect(window.location.assign).toBeCalledWith(href);
expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
});
});
it('invokes Flash when the dismiss request fails', () => {
mockAxios.onPost(dismissEndpoint).replyOnce(500);
link.click();
return waitForPromises().then(() => {
expect(window.location.assign).not.toHaveBeenCalled();
expect(Flash).toHaveBeenCalledWith(
'An error occurred while acknowledging the notification. Refresh the page and try again.',
);
});
});
});
describe('factory', () => { describe('factory', () => {
it('returns an instance of PersistentUserCallout with the provided container property', () => { it('returns an instance of PersistentUserCallout with the provided container property', () => {
const fixture = createFixture(); const fixture = createFixture();
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment