Commit ccd943e9 authored by Jay Swain's avatar Jay Swain

Whats New - Add "infinite" scroll/pagination

Adding infinite scroll and "pagination" to the "whats new" component.

Something worth mentioning is the integration between the drawer and the
infinite scroll. The Drawer component has the overflow-y attribute
declared and it conflicts with the infinite scroll component
receiving a scroll event. My solution is to hide any overflow from
the drawer, and set a fixed pixel amount to the infinite scroll
as a prop.

part of:
https://gitlab.com/gitlab-org/growth/engineering/-/issues/5388
parent dee082a8
<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,
GlInfiniteScroll,
GlResizeObserverDirective,
} from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue'; import SkeletonLoader from './skeleton_loader.vue';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
const trackingMixin = Tracking.mixin(); const trackingMixin = Tracking.mixin();
...@@ -12,8 +20,12 @@ export default { ...@@ -12,8 +20,12 @@ export default {
GlBadge, GlBadge,
GlIcon, GlIcon,
GlLink, GlLink,
GlInfiniteScroll,
SkeletonLoader, SkeletonLoader,
}, },
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin], mixins: [trackingMixin],
props: { props: {
storageKey: { storageKey: {
...@@ -23,7 +35,7 @@ export default { ...@@ -23,7 +35,7 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['open', 'features']), ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
}, },
mounted() { mounted() {
this.openDrawer(this.storageKey); this.openDrawer(this.storageKey);
...@@ -35,20 +47,41 @@ export default { ...@@ -35,20 +47,41 @@ 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', 'fetchItems']), ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
bottomReached() {
if (this.pageInfo.nextPage) {
this.fetchItems(this.pageInfo.nextPage);
}
},
handleResize() {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer"> <gl-drawer
ref="drawer"
v-gl-resize-observer="handleResize"
class="whats-new-drawer"
:open="open"
@close="closeDrawer"
>
<template #header> <template #header>
<h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4> <h4 class="page-title gl-my-3">{{ __("What's new at GitLab") }}</h4>
</template> </template>
<div class="pb-6"> <gl-infinite-scroll
<template v-if="features"> v-if="features.length"
<div v-for="feature in features" :key="feature.title" class="mb-6"> :fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@bottomReached="bottomReached"
>
<template #items>
<div v-for="feature in features" :key="feature.title" class="gl-mb-7 gl-px-5 gl-pt-5">
<gl-link <gl-link
:href="feature.url" :href="feature.url"
target="_blank" target="_blank"
...@@ -60,11 +93,14 @@ export default { ...@@ -60,11 +93,14 @@ export default {
<h5 class="gl-font-base">{{ feature.title }}</h5> <h5 class="gl-font-base">{{ feature.title }}</h5>
</gl-link> </gl-link>
<div v-if="feature.packages" class="gl-mb-3"> <div v-if="feature.packages" class="gl-mb-3">
<template v-for="package_name in feature.packages"> <gl-badge
<gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2"> v-for="package_name in feature.packages"
<gl-icon name="license" />{{ package_name }} :key="package_name"
</gl-badge> size="sm"
</template> class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ package_name }}
</gl-badge>
</div> </div>
<gl-link <gl-link
:href="feature.url" :href="feature.url"
...@@ -76,7 +112,7 @@ export default { ...@@ -76,7 +112,7 @@ export default {
<img <img
:alt="feature.title" :alt="feature.title"
:src="feature.image_url" :src="feature.image_url"
class="img-thumbnail px-6 gl-py-3 whats-new-item-image" class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
/> />
</gl-link> </gl-link>
<p class="gl-pt-3">{{ feature.body }}</p> <p class="gl-pt-3">{{ feature.body }}</p>
...@@ -90,10 +126,10 @@ export default { ...@@ -90,10 +126,10 @@ export default {
> >
</div> </div>
</template> </template>
<div v-else class="gl-mt-5"> </gl-infinite-scroll>
<skeleton-loader /> <div v-else class="gl-mt-5">
<skeleton-loader /> <skeleton-loader />
</div> <skeleton-loader />
</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>
......
import * as types from './mutation_types'; import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default { export default {
closeDrawer({ commit }) { closeDrawer({ commit }) {
...@@ -12,9 +13,33 @@ export default { ...@@ -12,9 +13,33 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false)); localStorage.setItem(storageKey, JSON.stringify(false));
} }
}, },
fetchItems({ commit }) { fetchItems({ commit, state }, page) {
return axios.get('/-/whats_new').then(({ data }) => { if (state.fetching) {
commit(types.SET_FEATURES, data); return false;
}); }
commit(types.SET_FETCHING, true);
return axios
.get('/-/whats_new', {
params: {
page,
},
})
.then(({ data, headers }) => {
commit(types.ADD_FEATURES, data);
const normalizedHeaders = normalizeHeaders(headers);
const { nextPage } = parseIntPagination(normalizedHeaders);
commit(types.SET_PAGE_INFO, {
nextPage,
});
})
.finally(() => {
commit(types.SET_FETCHING, false);
});
},
setDrawerBodyHeight({ commit }, height) {
commit(types.SET_DRAWER_BODY_HEIGHT, height);
}, },
}; };
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'; export const ADD_FEATURES = 'ADD_FEATURES';
export const SET_PAGE_INFO = 'SET_PAGE_INFO';
export const SET_FETCHING = 'SET_FETCHING';
export const SET_DRAWER_BODY_HEIGHT = 'SET_DRAWER_BODY_HEIGHT';
...@@ -7,7 +7,16 @@ export default { ...@@ -7,7 +7,16 @@ export default {
[types.OPEN_DRAWER](state) { [types.OPEN_DRAWER](state) {
state.open = true; state.open = true;
}, },
[types.SET_FEATURES](state, data) { [types.ADD_FEATURES](state, data) {
state.features = data; state.features = state.features.concat(data);
},
[types.SET_PAGE_INFO](state, pageInfo) {
state.pageInfo = pageInfo;
},
[types.SET_FETCHING](state, fetching) {
state.fetching = fetching;
},
[types.SET_DRAWER_BODY_HEIGHT](state, height) {
state.drawerBodyHeight = height;
}, },
}; };
export default { export default {
open: false, open: false,
features: null, features: [],
fetching: false,
drawerBodyHeight: null,
pageInfo: {
nextPage: null,
},
}; };
export const getDrawerBodyHeight = drawer => {
const drawerViewableHeight = drawer.clientHeight - drawer.getBoundingClientRect().top;
const drawerHeaderHeight = drawer.querySelector('.gl-drawer-header').clientHeight;
return drawerViewableHeight - drawerHeaderHeight;
};
.whats-new-drawer { .whats-new-drawer {
margin-top: $header-height; margin-top: $header-height;
@include gl-shadow-none; @include gl-shadow-none;
overflow-y: hidden;
.gl-infinite-scroll-legend {
@include gl-display-none;
}
} }
.with-performance-bar .whats-new-drawer { .with-performance-bar .whats-new-drawer {
......
...@@ -5,14 +5,14 @@ class WhatsNewController < ApplicationController ...@@ -5,14 +5,14 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
before_action :check_feature_flag before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers
feature_category :navigation feature_category :navigation
def index def index
respond_to do |format| respond_to do |format|
format.js do format.js do
render json: whats_new_most_recent_release_items render json: whats_new_release_items(page: current_page)
end end
end end
end end
...@@ -22,4 +22,23 @@ class WhatsNewController < ApplicationController ...@@ -22,4 +22,23 @@ class WhatsNewController < ApplicationController
def check_feature_flag def check_feature_flag
render_404 unless Feature.enabled?(:whats_new_drawer, current_user) render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
end end
def check_valid_page_param
render_404 if current_page < 1
end
def set_pagination_headers
response.set_header('X-Next-Page', next_page)
end
def current_page
params[:page]&.to_i || 1
end
def next_page
next_page = current_page + 1
next_index = next_page - 1
next_page if whats_new_file_paths[next_index]
end
end end
...@@ -5,7 +5,7 @@ module WhatsNewHelper ...@@ -5,7 +5,7 @@ module WhatsNewHelper
def whats_new_most_recent_release_items_count def whats_new_most_recent_release_items_count
Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do
whats_new_most_recent_release_items&.count whats_new_release_items&.count
end end
end end
...@@ -19,9 +19,7 @@ module WhatsNewHelper ...@@ -19,9 +19,7 @@ module WhatsNewHelper
def whats_new_most_recent_version def whats_new_most_recent_version
Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do
if whats_new_most_recent_release_items whats_new_release_items&.first&.[]('release')
whats_new_most_recent_release_items.first.try(:[], 'release')
end
end end
end end
end end
...@@ -2,27 +2,39 @@ ...@@ -2,27 +2,39 @@
module Gitlab module Gitlab
module WhatsNew module WhatsNew
CACHE_DURATION = 1.day CACHE_DURATION = 1.hour
WHATS_NEW_FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') WHATS_NEW_FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
private private
def whats_new_most_recent_release_items def whats_new_release_items(page: 1)
Rails.cache.fetch('whats_new:release_items', expires_in: CACHE_DURATION) do Rails.cache.fetch(whats_new_items_cache_key(page), expires_in: CACHE_DURATION) do
file = File.read(most_recent_release_file_path) index = page - 1
file_path = whats_new_file_paths[index]
next if file_path.nil?
file = File.read(file_path)
items = YAML.safe_load(file, permitted_classes: [Date]) items = YAML.safe_load(file, permitted_classes: [Date])
items if items.is_a?(Array) items if items.is_a?(Array)
end end
rescue => e rescue => e
Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path) Gitlab::ErrorTracking.track_exception(e, page: page)
nil nil
end end
def most_recent_release_file_path def whats_new_file_paths
@most_recent_release_file_path ||= Dir.glob(WHATS_NEW_FILES_PATH).max @whats_new_file_paths ||= Rails.cache.fetch('whats_new:file_paths', expires_in: CACHE_DURATION) do
Dir.glob(WHATS_NEW_FILES_PATH).sort.reverse
end
end
def whats_new_items_cache_key(page)
filename = /\d*\_\d*\_\d*/.match(whats_new_file_paths&.first)
"whats_new:release_items:file-#{filename}:page-#{page}"
end end
end end
end end
--- ---
- title: bright and sunshinin' day - title: bright and sunshinin' day
release: '01.05'
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlDrawer } from '@gitlab/ui'; import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import App from '~/whats_new/components/app.vue'; import App from '~/whats_new/components/app.vue';
import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
const MOCK_DRAWER_BODY_HEIGHT = 42;
jest.mock('~/whats_new/utils/get_drawer_body_height', () => ({
getDrawerBodyHeight: jest.fn().mockImplementation(() => MOCK_DRAWER_BODY_HEIGHT),
}));
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -20,11 +28,13 @@ describe('App', () => { ...@@ -20,11 +28,13 @@ describe('App', () => {
openDrawer: jest.fn(), openDrawer: jest.fn(),
closeDrawer: jest.fn(), closeDrawer: jest.fn(),
fetchItems: jest.fn(), fetchItems: jest.fn(),
setDrawerBodyHeight: jest.fn(),
}; };
state = { state = {
open: true, open: true,
features: null, features: [],
drawerBodyHeight: null,
}; };
store = new Vuex.Store({ store = new Vuex.Store({
...@@ -36,9 +46,15 @@ describe('App', () => { ...@@ -36,9 +46,15 @@ describe('App', () => {
localVue, localVue,
store, store,
propsData, propsData,
directives: {
GlResizeObserver: createMockDirective(),
},
}); });
}; };
const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
beforeEach(async () => { 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';
...@@ -47,6 +63,7 @@ describe('App', () => { ...@@ -47,6 +63,7 @@ describe('App', () => {
buildWrapper(); buildWrapper();
wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }]; wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }];
wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}); });
...@@ -61,7 +78,7 @@ describe('App', () => { ...@@ -61,7 +78,7 @@ describe('App', () => {
expect(getDrawer().exists()).toBe(true); expect(getDrawer().exists()).toBe(true);
}); });
it('dispatches openDrawer when mounted', () => { it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id', label: 'namespace_id',
...@@ -102,4 +119,46 @@ describe('App', () => { ...@@ -102,4 +119,46 @@ describe('App', () => {
}, },
]); ]);
}); });
it('renders infinite scroll', () => {
const scroll = findInfiniteScroll();
expect(scroll.props()).toMatchObject({
fetchedItems: wrapper.vm.$store.state.features.length,
maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
});
});
describe('bottomReached', () => {
beforeEach(() => {
actions.fetchItems.mockClear();
});
it('when nextPage exists it calls fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
emitBottomReached();
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840);
});
it('when nextPage does not exist it does not call fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: null };
emitBottomReached();
expect(actions.fetchItems).not.toHaveBeenCalled();
});
});
it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
value();
expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element);
expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
expect.any(Object),
MOCK_DRAWER_BODY_HEIGHT,
);
});
}); });
...@@ -30,7 +30,9 @@ describe('whats new actions', () => { ...@@ -30,7 +30,9 @@ describe('whats new actions', () => {
axiosMock = new MockAdapter(axios); axiosMock = new MockAdapter(axios);
axiosMock axiosMock
.onGet('/-/whats_new') .onGet('/-/whats_new')
.replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]); .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }], {
'x-next-page': '2',
});
await waitForPromises(); await waitForPromises();
}); });
...@@ -39,10 +41,23 @@ describe('whats new actions', () => { ...@@ -39,10 +41,23 @@ describe('whats new actions', () => {
axiosMock.restore(); axiosMock.restore();
}); });
it('should commit setFeatures', () => { it('if already fetching, does not fetch', () => {
testAction(actions.fetchItems, {}, { fetching: true }, []);
});
it('should commit fetching, setFeatures and setPagination', () => {
testAction(actions.fetchItems, {}, {}, [ testAction(actions.fetchItems, {}, {}, [
{ type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] }, { type: types.SET_FETCHING, payload: true },
{ type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
{ type: types.SET_PAGE_INFO, payload: { nextPage: 2 } },
{ type: types.SET_FETCHING, payload: false },
]); ]);
}); });
}); });
describe('setDrawerBodyHeight', () => {
testAction(actions.setDrawerBodyHeight, 42, {}, [
{ type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 },
]);
});
}); });
...@@ -23,10 +23,37 @@ describe('whats new mutations', () => { ...@@ -23,10 +23,37 @@ describe('whats new mutations', () => {
}); });
}); });
describe('setFeatures', () => { describe('addFeatures', () => {
it('sets features to data', () => { it('adds features from data', () => {
mutations[types.SET_FEATURES](state, 'bells and whistles'); mutations[types.ADD_FEATURES](state, ['bells and whistles']);
expect(state.features).toBe('bells and whistles'); expect(state.features).toEqual(['bells and whistles']);
});
it('when there are already items, it adds items', () => {
state.features = ['shiny things'];
mutations[types.ADD_FEATURES](state, ['bells and whistles']);
expect(state.features).toEqual(['shiny things', 'bells and whistles']);
});
});
describe('setPageInfo', () => {
it('sets page info', () => {
mutations[types.SET_PAGE_INFO](state, { nextPage: 8 });
expect(state.pageInfo).toEqual({ nextPage: 8 });
});
});
describe('setFetching', () => {
it('sets fetching', () => {
mutations[types.SET_FETCHING](state, true);
expect(state.fetching).toBe(true);
});
});
describe('setDrawerBodyHeight', () => {
it('sets drawerBodyHeight', () => {
mutations[types.SET_DRAWER_BODY_HEIGHT](state, 840);
expect(state.drawerBodyHeight).toBe(840);
}); });
}); });
}); });
import { mount } from '@vue/test-utils';
import { GlDrawer } from '@gitlab/ui';
import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
describe('~/whats_new/utils/get_drawer_body_height', () => {
let drawerWrapper;
beforeEach(() => {
drawerWrapper = mount(GlDrawer, {
propsData: { open: true },
});
});
afterEach(() => {
drawerWrapper.destroy();
});
const setClientHeight = (el, height) => {
Object.defineProperty(el, 'clientHeight', {
get() {
return height;
},
});
};
const setDrawerDimensions = ({ height, top, headerHeight }) => {
const drawer = drawerWrapper.element;
setClientHeight(drawer, height);
jest.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ top });
setClientHeight(drawer.querySelector('.gl-drawer-header'), headerHeight);
};
it('calculates height of drawer body', () => {
setDrawerDimensions({ height: 100, top: 5, headerHeight: 40 });
expect(getDrawerBodyHeight(drawerWrapper.element)).toBe(55);
});
});
...@@ -3,21 +3,23 @@ ...@@ -3,21 +3,23 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe WhatsNewHelper do RSpec.describe WhatsNewHelper do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
describe '#whats_new_storage_key' do describe '#whats_new_storage_key' do
subject { helper.whats_new_storage_key } subject { helper.whats_new_storage_key }
before do
allow(helper).to receive(:whats_new_most_recent_version).and_return(version)
end
context 'when version exist' do context 'when version exist' do
let(:version) { '84.0' } before do
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end
it { is_expected.to eq('display-whats-new-notification-84.0') } it { is_expected.to eq('display-whats-new-notification-01.05') }
end end
context 'when recent release items do NOT exist' do context 'when recent release items do NOT exist' do
let(:version) { nil } before do
allow(helper).to receive(:whats_new_release_items).and_return(nil)
end
it { is_expected.to be_nil } it { is_expected.to be_nil }
end end
...@@ -27,8 +29,6 @@ RSpec.describe WhatsNewHelper do ...@@ -27,8 +29,6 @@ RSpec.describe WhatsNewHelper do
subject { helper.whats_new_most_recent_release_items_count } subject { helper.whats_new_most_recent_release_items_count }
context 'when recent release items exist' do context 'when recent release items exist' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
it 'returns the count from the most recent file' do it 'returns the count from the most recent file' do
expect(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob) expect(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
...@@ -48,4 +48,13 @@ RSpec.describe WhatsNewHelper do ...@@ -48,4 +48,13 @@ RSpec.describe WhatsNewHelper do
end end
end end
end end
# Testing this important private method here because the request spec required multiple confusing mocks and felt wrong and overcomplicated
describe '#whats_new_items_cache_key' do
it 'returns a key containing the most recent file name and page parameter' do
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
expect(helper.send(:whats_new_items_cache_key, 2)).to eq('whats_new:release_items:file-20201225_01_05:page-2')
end
end
end end
...@@ -4,19 +4,44 @@ require 'spec_helper' ...@@ -4,19 +4,44 @@ require 'spec_helper'
RSpec.describe WhatsNewController do RSpec.describe WhatsNewController do
describe 'whats_new_path' 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 context 'with whats_new_drawer feature enabled' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
before do before do
stub_feature_flags(whats_new_drawer: true) stub_feature_flags(whats_new_drawer: true)
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end end
it 'is successful' do context 'with no page param' do
get whats_new_path, xhr: true it 'responds with paginated data and headers' do
get whats_new_path, xhr: true
expect(response.body).to eq([{ title: "bright and sunshinin' day", release: "01.05" }].to_json)
expect(response.headers['X-Next-Page']).to eq(2)
end
end
context 'with page param' do
it 'responds with paginated data and headers' do
get whats_new_path(page: 2), xhr: true
expect(response.body).to eq([{ title: 'bright' }].to_json)
expect(response.headers['X-Next-Page']).to eq(3)
end
it 'returns a 404 if page param is negative' do
get whats_new_path(page: -1), xhr: true
expect(response).to have_gitlab_http_status(:not_found)
end
expect(response).to have_gitlab_http_status(:ok) context 'when there are no more paginated results' do
it 'responds with nil X-Next-Page header' do
get whats_new_path(page: 3), xhr: true
expect(response.body).to eq([{ title: "It's gonna be a bright" }].to_json)
expect(response.headers['X-Next-Page']).to be nil
end
end
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