Commit f6208737 authored by Imre Farkas's avatar Imre Farkas

Merge branch 'jswain_whats_new_async' into 'master'

Make "Whats New" async

See merge request gitlab-org/gitlab!43202
parents 2fd4b310 42488c6d
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin(); const trackingMixin = Tracking.mixin();
...@@ -11,14 +12,10 @@ export default { ...@@ -11,14 +12,10 @@ export default {
GlBadge, GlBadge,
GlIcon, GlIcon,
GlLink, GlLink,
SkeletonLoader,
}, },
mixins: [trackingMixin], mixins: [trackingMixin],
props: { props: {
features: {
type: String,
required: false,
default: null,
},
storageKey: { storageKey: {
type: String, type: String,
required: true, required: true,
...@@ -26,21 +23,11 @@ export default { ...@@ -26,21 +23,11 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['open']), ...mapState(['open', 'features']),
parsedFeatures() {
let features;
try {
features = JSON.parse(this.$props.features) || [];
} catch (err) {
features = [];
}
return features;
},
}, },
mounted() { mounted() {
this.openDrawer(this.storageKey); this.openDrawer(this.storageKey);
this.fetchItems();
const body = document.querySelector('body'); const body = document.querySelector('body');
const namespaceId = body.getAttribute('data-namespace-id'); const namespaceId = body.getAttribute('data-namespace-id');
...@@ -48,7 +35,7 @@ export default { ...@@ -48,7 +35,7 @@ export default {
this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
}, },
methods: { methods: {
...mapActions(['openDrawer', 'closeDrawer']), ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']),
}, },
}; };
</script> </script>
...@@ -60,7 +47,8 @@ export default { ...@@ -60,7 +47,8 @@ export default {
<h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4> <h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4>
</template> </template>
<div class="pb-6"> <div class="pb-6">
<div v-for="feature in parsedFeatures" :key="feature.title" class="mb-6"> <template v-if="features">
<div v-for="feature in features" :key="feature.title" class="mb-6">
<gl-link <gl-link
:href="feature.url" :href="feature.url"
target="_blank" target="_blank"
...@@ -71,9 +59,9 @@ export default { ...@@ -71,9 +59,9 @@ export default {
> >
<h5 class="gl-font-base">{{ feature.title }}</h5> <h5 class="gl-font-base">{{ feature.title }}</h5>
</gl-link> </gl-link>
<div class="mb-2"> <div v-if="feature.packages" class="gl-mb-3">
<template v-for="package_name in feature.packages"> <template v-for="package_name in feature.packages">
<gl-badge :key="package_name" size="sm" class="whats-new-item-badge mr-1"> <gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2">
<gl-icon name="license" />{{ package_name }} <gl-icon name="license" />{{ package_name }}
</gl-badge> </gl-badge>
</template> </template>
...@@ -88,10 +76,10 @@ export default { ...@@ -88,10 +76,10 @@ export default {
<img <img
:alt="feature.title" :alt="feature.title"
:src="feature.image_url" :src="feature.image_url"
class="img-thumbnail px-6 py-2 whats-new-item-image" class="img-thumbnail px-6 gl-py-3 whats-new-item-image"
/> />
</gl-link> </gl-link>
<p class="pt-2">{{ feature.body }}</p> <p class="gl-pt-3">{{ feature.body }}</p>
<gl-link <gl-link
:href="feature.url" :href="feature.url"
target="_blank" target="_blank"
...@@ -101,6 +89,11 @@ export default { ...@@ -101,6 +89,11 @@ export default {
>{{ __('Learn more') }}</gl-link >{{ __('Learn more') }}</gl-link
> >
</div> </div>
</template>
<div v-else class="gl-mt-5">
<skeleton-loader />
<skeleton-loader />
</div>
</div> </div>
</gl-drawer> </gl-drawer>
<div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div> <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
......
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
};
</script>
<template>
<gl-skeleton-loader :width="350" :height="420">
<rect width="350" height="16" />
<rect y="25" width="110" height="16" rx="8" />
<rect x="115" y="25" width="110" height="16" rx="8" />
<rect x="230" y="25" width="110" height="16" rx="8" />
<rect y="50" width="350" height="165" rx="12" />
<rect y="230" width="480" height="8" />
<rect y="254" width="560" height="8" />
<rect y="278" width="320" height="8" />
<rect y="302" width="480" height="8" />
<rect y="326" width="560" height="8" />
<rect y="365" width="80" height="8" />
</gl-skeleton-loader>
</template>
...@@ -19,7 +19,6 @@ export default () => { ...@@ -19,7 +19,6 @@ export default () => {
render(createElement) { render(createElement) {
return createElement('app', { return createElement('app', {
props: { props: {
features: whatsNewElm.getAttribute('data-features'),
storageKey: whatsNewElm.getAttribute('data-storage-key'), storageKey: whatsNewElm.getAttribute('data-storage-key'),
}, },
}); });
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
export default { export default {
closeDrawer({ commit }) { closeDrawer({ commit }) {
...@@ -11,4 +12,9 @@ export default { ...@@ -11,4 +12,9 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false)); localStorage.setItem(storageKey, JSON.stringify(false));
} }
}, },
fetchItems({ commit }) {
return axios.get('/-/whats_new').then(({ data }) => {
commit(types.SET_FEATURES, data);
});
},
}; };
export const CLOSE_DRAWER = 'CLOSE_DRAWER'; export const CLOSE_DRAWER = 'CLOSE_DRAWER';
export const OPEN_DRAWER = 'OPEN_DRAWER'; export const OPEN_DRAWER = 'OPEN_DRAWER';
export const SET_FEATURES = 'SET_FEATURES';
...@@ -7,4 +7,7 @@ export default { ...@@ -7,4 +7,7 @@ export default {
[types.OPEN_DRAWER](state) { [types.OPEN_DRAWER](state) {
state.open = true; state.open = true;
}, },
[types.SET_FEATURES](state, data) {
state.features = data;
},
}; };
export default { export default {
open: false, open: false,
features: null,
}; };
# frozen_string_literal: true
class WhatsNewController < ApplicationController
include Gitlab::WhatsNew
skip_before_action :authenticate_user!
before_action :check_feature_flag
feature_category :navigation
def index
respond_to do |format|
format.js do
render json: whats_new_most_recent_release_items
end
end
end
private
def check_feature_flag
render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
end
end
# frozen_string_literal: true # frozen_string_literal: true
module WhatsNewHelper module WhatsNewHelper
EMPTY_JSON = ''.to_json include Gitlab::WhatsNew
def whats_new_most_recent_release_items_count def whats_new_most_recent_release_items_count
items = parsed_most_recent_release_items Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do
whats_new_most_recent_release_items&.count
return unless items.is_a?(Array)
items.count
end end
def whats_new_storage_key
items = parsed_most_recent_release_items
return unless items.is_a?(Array)
release = items.first.try(:[], 'release')
['display-whats-new-notification', release].compact.join('-')
end end
def whats_new_most_recent_release_items def whats_new_storage_key
YAML.load_file(most_recent_release_file_path).to_json return unless whats_new_most_recent_version
rescue => e
Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path)
EMPTY_JSON ['display-whats-new-notification', whats_new_most_recent_version].join('-')
end end
private private
def parsed_most_recent_release_items def whats_new_most_recent_version
Gitlab::Json.parse(whats_new_most_recent_release_items) Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do
if whats_new_most_recent_release_items
whats_new_most_recent_release_items.first.try(:[], 'release')
end end
def most_recent_release_file_path
Dir.glob(files_path).max
end end
def files_path
Rails.root.join('data', 'whats_new', '*.yml')
end end
end end
...@@ -99,8 +99,8 @@ ...@@ -99,8 +99,8 @@
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right') = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right')
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
- if ::Feature.enabled?(:whats_new_drawer) - if ::Feature.enabled?(:whats_new_drawer, current_user)
#whats-new-app{ data: { features: whats_new_most_recent_release_items, storage_key: whats_new_storage_key } } #whats-new-app{ data: { storage_key: whats_new_storage_key } }
- if can?(current_user, :update_user_status, current_user) - if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } } .js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } }
...@@ -83,6 +83,8 @@ Rails.application.routes.draw do ...@@ -83,6 +83,8 @@ Rails.application.routes.draw do
get '/autocomplete/namespace_routes' => 'autocomplete#namespace_routes' get '/autocomplete/namespace_routes' => 'autocomplete#namespace_routes'
end end
get '/whats_new' => 'whats_new#index'
# '/-/health' implemented by BasicHealthCheck middleware # '/-/health' implemented by BasicHealthCheck middleware
get 'liveness' => 'health#liveness' get 'liveness' => 'health#liveness'
get 'readiness' => 'health#readiness' get 'readiness' => 'health#readiness'
......
- if ::Feature.enabled?(:whats_new_dropdown) - if ::Feature.enabled?(:whats_new_dropdown, current_user)
- if ::Feature.enabled?(:whats_new_drawer) - if ::Feature.enabled?(:whats_new_drawer, current_user)
%li %li
%button.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key } } %button.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key } }
= _("See what's new at GitLab") = _("See what's new at GitLab")
......
# frozen_string_literal: true
module Gitlab
module WhatsNew
CACHE_DURATION = 1.day
WHATS_NEW_FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
private
def whats_new_most_recent_release_items
Rails.cache.fetch('whats_new:release_items', expires_in: CACHE_DURATION) do
file = File.read(most_recent_release_file_path)
items = YAML.safe_load(file, permitted_classes: [Date])
items if items.is_a?(Array)
end
rescue => e
Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path)
nil
end
def most_recent_release_file_path
@most_recent_release_file_path ||= Dir.glob(WHATS_NEW_FILES_PATH).max
end
end
end
...@@ -8,21 +8,23 @@ const localVue = createLocalVue(); ...@@ -8,21 +8,23 @@ const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
describe('App', () => { describe('App', () => {
const propsData = { storageKey: 'storage-key' };
let wrapper; let wrapper;
let store; let store;
let actions; let actions;
let state; let state;
let propsData = { features: '[ {"title":"Whats New Drawer"} ]', storageKey: 'storage-key' };
let trackingSpy; let trackingSpy;
const buildWrapper = () => { const buildWrapper = () => {
actions = { actions = {
openDrawer: jest.fn(), openDrawer: jest.fn(),
closeDrawer: jest.fn(), closeDrawer: jest.fn(),
fetchItems: jest.fn(),
}; };
state = { state = {
open: true, open: true,
features: null,
}; };
store = new Vuex.Store({ store = new Vuex.Store({
...@@ -37,12 +39,15 @@ describe('App', () => { ...@@ -37,12 +39,15 @@ describe('App', () => {
}); });
}; };
beforeEach(() => { beforeEach(async () => {
document.body.dataset.page = 'test-page'; document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840'; document.body.dataset.namespaceId = 'namespace-840';
trackingSpy = mockTracking('_category_', null, jest.spyOn); trackingSpy = mockTracking('_category_', null, jest.spyOn);
buildWrapper(); buildWrapper();
wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }];
await wrapper.vm.$nextTick();
}); });
afterEach(() => { afterEach(() => {
...@@ -77,29 +82,18 @@ describe('App', () => { ...@@ -77,29 +82,18 @@ describe('App', () => {
expect(getDrawer().props('open')).toBe(openState); expect(getDrawer().props('open')).toBe(openState);
}); });
it('renders features when provided as props', () => { it('renders features when provided via ajax', () => {
expect(actions.fetchItems).toHaveBeenCalled();
expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
}); });
it('handles bad json argument gracefully', () => {
propsData = { features: 'this is not json', storageKey: 'storage-key' };
buildWrapper();
expect(getDrawer().exists()).toBe(true);
});
it('send an event when feature item is clicked', () => { it('send an event when feature item is clicked', () => {
propsData = {
features: '[ {"title":"Whats New Drawer", "url": "www.url.com"} ]',
storageKey: 'storage-key',
};
buildWrapper();
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
const link = wrapper.find('[data-testid="whats-new-title-link"]'); const link = wrapper.find('[data-testid="whats-new-title-link"]');
triggerEvent(link.element); triggerEvent(link.element);
expect(trackingSpy.mock.calls[2]).toMatchObject([ expect(trackingSpy.mock.calls[1]).toMatchObject([
'_category_', '_category_',
'click_whats_new_item', 'click_whats_new_item',
{ {
......
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import actions from '~/whats_new/store/actions'; import actions from '~/whats_new/store/actions';
import * as types from '~/whats_new/store/mutation_types'; import * as types from '~/whats_new/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
describe('whats new actions', () => { describe('whats new actions', () => {
describe('openDrawer', () => { describe('openDrawer', () => {
...@@ -19,4 +22,27 @@ describe('whats new actions', () => { ...@@ -19,4 +22,27 @@ describe('whats new actions', () => {
testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]); testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]);
}); });
}); });
describe('fetchItems', () => {
let axiosMock;
beforeEach(async () => {
axiosMock = new MockAdapter(axios);
axiosMock
.onGet('/-/whats_new')
.replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]);
await waitForPromises();
});
afterEach(() => {
axiosMock.restore();
});
it('should commit setFeatures', () => {
testAction(actions.fetchItems, {}, {}, [
{ type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
]);
});
});
}); });
...@@ -22,4 +22,11 @@ describe('whats new mutations', () => { ...@@ -22,4 +22,11 @@ describe('whats new mutations', () => {
expect(state.open).toBe(false); expect(state.open).toBe(false);
}); });
}); });
describe('setFeatures', () => {
it('sets features to data', () => {
mutations[types.SET_FEATURES](state, 'bells and whistles');
expect(state.features).toBe('bells and whistles');
});
});
}); });
...@@ -7,23 +7,17 @@ RSpec.describe WhatsNewHelper do ...@@ -7,23 +7,17 @@ RSpec.describe WhatsNewHelper do
subject { helper.whats_new_storage_key } subject { helper.whats_new_storage_key }
before do before do
allow(helper).to receive(:whats_new_most_recent_release_items).and_return(json) allow(helper).to receive(:whats_new_most_recent_version).and_return(version)
end end
context 'when recent release items exist' do context 'when version exist' do
let(:json) { [{ release: 84.0 }].to_json } let(:version) { '84.0' }
it { is_expected.to eq('display-whats-new-notification-84.0') } it { is_expected.to eq('display-whats-new-notification-84.0') }
context 'when the release items are missing the release key' do
let(:json) { [{ title: 'bells!' }].to_json }
it { is_expected.to eq('display-whats-new-notification') }
end
end end
context 'when recent release items do NOT exist' do context 'when recent release items do NOT exist' do
let(:json) { WhatsNewHelper::EMPTY_JSON } let(:version) { nil }
it { is_expected.to be_nil } it { is_expected.to be_nil }
end end
...@@ -32,37 +26,26 @@ RSpec.describe WhatsNewHelper do ...@@ -32,37 +26,26 @@ RSpec.describe WhatsNewHelper do
describe '#whats_new_most_recent_release_items_count' do describe '#whats_new_most_recent_release_items_count' do
subject { helper.whats_new_most_recent_release_items_count } subject { helper.whats_new_most_recent_release_items_count }
before do
allow(helper).to receive(:whats_new_most_recent_release_items).and_return(json)
end
context 'when recent release items exist' do context 'when recent release items exist' do
let(:json) { [:bells, :and, :whistles].to_json } let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
it { is_expected.to eq(3) }
end
context 'when recent release items do NOT exist' do it 'returns the count from the most recent file' do
let(:json) { WhatsNewHelper::EMPTY_JSON } expect(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
it { is_expected.to be_nil } expect(subject).to eq(1)
end end
end end
describe '#whats_new_most_recent_release_items' do context 'when recent release items do NOT exist' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) } before do
allow(YAML).to receive(:safe_load).and_raise
it 'returns json from the most recent file' do
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
expect(helper.whats_new_most_recent_release_items).to include({ title: "bright and sunshinin' day" }.to_json) expect(Gitlab::ErrorTracking).to receive(:track_exception)
end end
it 'fails gracefully and logs an error' do it 'fails gracefully and logs an error' do
allow(YAML).to receive(:load_file).and_raise expect(subject).to be_nil
end
expect(Gitlab::ErrorTracking).to receive(:track_exception)
expect(helper.whats_new_most_recent_release_items).to eq(''.to_json)
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WhatsNewController do
describe 'whats_new_path' do
before do
allow_any_instance_of(WhatsNewController).to receive(:whats_new_most_recent_release_items).and_return('items')
end
context 'with whats_new_drawer feature enabled' do
before do
stub_feature_flags(whats_new_drawer: true)
end
it 'is successful' do
get whats_new_path, xhr: true
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with whats_new_drawer feature disabled' do
before do
stub_feature_flags(whats_new_drawer: false)
end
it 'returns a 404' do
get whats_new_path, xhr: true
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
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