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>
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 Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
const trackingMixin = Tracking.mixin();
......@@ -12,8 +20,12 @@ export default {
GlBadge,
GlIcon,
GlLink,
GlInfiniteScroll,
SkeletonLoader,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin],
props: {
storageKey: {
......@@ -23,7 +35,7 @@ export default {
},
},
computed: {
...mapState(['open', 'features']),
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
},
mounted() {
this.openDrawer(this.storageKey);
......@@ -35,20 +47,41 @@ export default {
this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
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>
<template>
<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>
<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>
<div class="pb-6">
<template v-if="features">
<div v-for="feature in features" :key="feature.title" class="mb-6">
<gl-infinite-scroll
v-if="features.length"
: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
:href="feature.url"
target="_blank"
......@@ -60,11 +93,14 @@ export default {
<h5 class="gl-font-base">{{ feature.title }}</h5>
</gl-link>
<div v-if="feature.packages" class="gl-mb-3">
<template v-for="package_name in feature.packages">
<gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2">
<gl-badge
v-for="package_name in feature.packages"
:key="package_name"
size="sm"
class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ package_name }}
</gl-badge>
</template>
</div>
<gl-link
:href="feature.url"
......@@ -76,7 +112,7 @@ export default {
<img
:alt="feature.title"
: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>
<p class="gl-pt-3">{{ feature.body }}</p>
......@@ -90,11 +126,11 @@ export default {
>
</div>
</template>
</gl-infinite-scroll>
<div v-else class="gl-mt-5">
<skeleton-loader />
<skeleton-loader />
</div>
</div>
</gl-drawer>
<div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
</div>
......
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
closeDrawer({ commit }) {
......@@ -12,9 +13,33 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false));
}
},
fetchItems({ commit }) {
return axios.get('/-/whats_new').then(({ data }) => {
commit(types.SET_FEATURES, data);
fetchItems({ commit, state }, page) {
if (state.fetching) {
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 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 {
[types.OPEN_DRAWER](state) {
state.open = true;
},
[types.SET_FEATURES](state, data) {
state.features = data;
[types.ADD_FEATURES](state, 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 {
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 {
margin-top: $header-height;
@include gl-shadow-none;
overflow-y: hidden;
.gl-infinite-scroll-legend {
@include gl-display-none;
}
}
.with-performance-bar .whats-new-drawer {
......
......@@ -5,14 +5,14 @@ class WhatsNewController < ApplicationController
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
def index
respond_to do |format|
format.js do
render json: whats_new_most_recent_release_items
render json: whats_new_release_items(page: current_page)
end
end
end
......@@ -22,4 +22,23 @@ class WhatsNewController < ApplicationController
def check_feature_flag
render_404 unless Feature.enabled?(:whats_new_drawer, current_user)
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
......@@ -5,7 +5,7 @@ module WhatsNewHelper
def whats_new_most_recent_release_items_count
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
......@@ -19,9 +19,7 @@ module WhatsNewHelper
def whats_new_most_recent_version
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
whats_new_release_items&.first&.[]('release')
end
end
end
......@@ -2,27 +2,39 @@
module Gitlab
module WhatsNew
CACHE_DURATION = 1.day
CACHE_DURATION = 1.hour
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)
def whats_new_release_items(page: 1)
Rails.cache.fetch(whats_new_items_cache_key(page), expires_in: CACHE_DURATION) do
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 if items.is_a?(Array)
end
rescue => e
Gitlab::ErrorTracking.track_exception(e, yaml_file_path: most_recent_release_file_path)
Gitlab::ErrorTracking.track_exception(e, page: page)
nil
end
def most_recent_release_file_path
@most_recent_release_file_path ||= Dir.glob(WHATS_NEW_FILES_PATH).max
def whats_new_file_paths
@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
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDrawer } from '@gitlab/ui';
import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
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 { 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();
localVue.use(Vuex);
......@@ -20,11 +28,13 @@ describe('App', () => {
openDrawer: jest.fn(),
closeDrawer: jest.fn(),
fetchItems: jest.fn(),
setDrawerBodyHeight: jest.fn(),
};
state = {
open: true,
features: null,
features: [],
drawerBodyHeight: null,
};
store = new Vuex.Store({
......@@ -36,9 +46,15 @@ describe('App', () => {
localVue,
store,
propsData,
directives: {
GlResizeObserver: createMockDirective(),
},
});
};
const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
beforeEach(async () => {
document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840';
......@@ -47,6 +63,7 @@ describe('App', () => {
buildWrapper();
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();
});
......@@ -61,7 +78,7 @@ describe('App', () => {
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(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
......@@ -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', () => {
axiosMock = new MockAdapter(axios);
axiosMock
.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();
});
......@@ -39,10 +41,23 @@ describe('whats new actions', () => {
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, {}, {}, [
{ 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', () => {
});
});
describe('setFeatures', () => {
it('sets features to data', () => {
mutations[types.SET_FEATURES](state, 'bells and whistles');
expect(state.features).toBe('bells and whistles');
describe('addFeatures', () => {
it('adds features from data', () => {
mutations[types.ADD_FEATURES](state, ['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 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
describe '#whats_new_storage_key' do
subject { helper.whats_new_storage_key }
context 'when version exist' do
before do
allow(helper).to receive(:whats_new_most_recent_version).and_return(version)
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end
context 'when version exist' do
let(:version) { '84.0' }
it { is_expected.to eq('display-whats-new-notification-84.0') }
it { is_expected.to eq('display-whats-new-notification-01.05') }
end
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 }
end
......@@ -27,8 +29,6 @@ RSpec.describe WhatsNewHelper do
subject { helper.whats_new_most_recent_release_items_count }
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
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
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
......@@ -4,19 +4,44 @@ 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
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
before do
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
it 'is successful' do
context 'with no page param' do
it 'responds with paginated data and headers' do
get whats_new_path, xhr: true
expect(response).to have_gitlab_http_status(:ok)
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
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
......
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