Commit 44b4326b authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch 'swimlanes/performance-virtual-list' into 'master'

Swimlanes - Use VirtualList to improve loading experience [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!56614
parents 985e28e0 59a89e43
...@@ -10,6 +10,7 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -10,6 +10,7 @@ class Groups::BoardsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false) push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false)
push_frontend_feature_flag(:boards_filtered_search, group) push_frontend_feature_flag(:boards_filtered_search, group)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
end end
feature_category :boards feature_category :boards
......
...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController ...@@ -9,6 +9,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
before_action do before_action do
push_frontend_feature_flag(:add_issues_button) push_frontend_feature_flag(:add_issues_button)
push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
end end
feature_category :boards feature_category :boards
......
---
name: swimlanes_buffered_rendering
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56614
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/324994
milestone: '13.11'
type: development
group: group::product planning
default_enabled: false
\ No newline at end of file
...@@ -3,6 +3,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -3,6 +3,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import { objectToQuery } from '~/lib/utils/url_utility'; import { objectToQuery } from '~/lib/utils/url_utility';
import { import {
EPIC_LANE_BASE_HEIGHT,
IterationFilterType, IterationFilterType,
IterationIDs, IterationIDs,
MilestoneFilterType, MilestoneFilterType,
...@@ -31,6 +32,10 @@ export function fullEpicBoardId(epicBoardId) { ...@@ -31,6 +32,10 @@ export function fullEpicBoardId(epicBoardId) {
return `gid://gitlab/Boards::EpicBoard/${epicBoardId}`; return `gid://gitlab/Boards::EpicBoard/${epicBoardId}`;
} }
export function calculateSwimlanesBufferSize(listTopCoordinate) {
return Math.ceil((window.innerHeight - listTopCoordinate) / EPIC_LANE_BASE_HEIGHT);
}
export function formatListEpics(listEpics) { export function formatListEpics(listEpics) {
const boardItems = {}; const boardItems = {};
let listItemsCount; let listItemsCount;
......
<script> <script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import VirtualList from 'vue-virtual-scroll-list';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
...@@ -7,11 +8,15 @@ import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue' ...@@ -7,11 +8,15 @@ import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'
import { isListDraggable } from '~/boards/boards_util'; import { isListDraggable } from '~/boards/boards_util';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import { DRAGGABLE_TAG } from '../constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { calculateSwimlanesBufferSize } from '../boards_util';
import { DRAGGABLE_TAG, EPIC_LANE_BASE_HEIGHT } from '../constants';
import EpicLane from './epic_lane.vue'; import EpicLane from './epic_lane.vue';
import IssuesLaneList from './issues_lane_list.vue'; import IssuesLaneList from './issues_lane_list.vue';
export default { export default {
EpicLane,
epicLaneBaseHeight: EPIC_LANE_BASE_HEIGHT,
components: { components: {
BoardAddNewColumn, BoardAddNewColumn,
BoardListHeader, BoardListHeader,
...@@ -19,10 +24,12 @@ export default { ...@@ -19,10 +24,12 @@ export default {
IssuesLaneList, IssuesLaneList,
GlButton, GlButton,
GlIcon, GlIcon,
VirtualList,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
lists: { lists: {
type: Array, type: Array,
...@@ -38,6 +45,11 @@ export default { ...@@ -38,6 +45,11 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
bufferSize: 0,
};
},
computed: { computed: {
...mapState(['epics', 'pageInfoByListId', 'listsFlags', 'addColumnForm']), ...mapState(['epics', 'pageInfoByListId', 'listsFlags', 'addColumnForm']),
...mapGetters(['getUnassignedIssues']), ...mapGetters(['getUnassignedIssues']),
...@@ -78,6 +90,9 @@ export default { ...@@ -78,6 +90,9 @@ export default {
); );
}, },
}, },
mounted() {
this.bufferSize = calculateSwimlanesBufferSize(this.$el.offsetTop);
},
methods: { methods: {
...mapActions(['moveList', 'fetchItemsForList']), ...mapActions(['moveList', 'fetchItemsForList']),
handleDragOnEnd(params) { handleDragOnEnd(params) {
...@@ -109,6 +124,17 @@ export default { ...@@ -109,6 +124,17 @@ export default {
behavior: 'smooth', behavior: 'smooth',
}); });
}, },
getEpicLaneProps(index) {
return {
key: this.epics[index].id,
props: {
epic: this.epics[index],
lists: this.lists,
disabled: this.disabled,
canAdminList: this.canAdminList,
},
};
},
}, },
}; };
</script> </script>
...@@ -148,6 +174,19 @@ export default { ...@@ -148,6 +174,19 @@ export default {
</div> </div>
</component> </component>
<div class="board-epics-swimlanes gl-display-table"> <div class="board-epics-swimlanes gl-display-table">
<template v-if="glFeatures.swimlanesBufferedRendering">
<virtual-list
v-if="epics.length"
:size="$options.epicLaneBaseHeight"
:remain="bufferSize"
:bench="bufferSize"
:scrollelement="$refs.scrollableContainer"
:item="$options.EpicLane"
:itemcount="epics.length"
:itemprops="getEpicLaneProps"
/>
</template>
<template v-else>
<epic-lane <epic-lane
v-for="epic in epics" v-for="epic in epics"
:key="epic.id" :key="epic.id"
...@@ -156,6 +195,7 @@ export default { ...@@ -156,6 +195,7 @@ export default {
:disabled="disabled" :disabled="disabled"
:can-admin-list="canAdminList" :can-admin-list="canAdminList"
/> />
</template>
<div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0"> <div class="board-lane-unassigned-issues-title gl-sticky gl-display-inline-block gl-left-0">
<div class="gl-left-0 gl-pb-5 gl-px-3 gl-display-flex gl-align-items-center"> <div class="gl-left-0 gl-pb-5 gl-px-3 gl-display-flex gl-align-items-center">
<span <span
......
...@@ -2,6 +2,8 @@ import { s__ } from '~/locale'; ...@@ -2,6 +2,8 @@ import { s__ } from '~/locale';
export const DRAGGABLE_TAG = 'div'; export const DRAGGABLE_TAG = 'div';
export const EPIC_LANE_BASE_HEIGHT = 40;
/* eslint-disable @gitlab/require-i18n-strings */ /* eslint-disable @gitlab/require-i18n-strings */
export const EpicFilterType = { export const EpicFilterType = {
any: 'Any', any: 'Any',
......
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VirtualList from 'vue-virtual-scroll-list';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { calculateSwimlanesBufferSize } from 'ee/boards/boards_util';
import EpicLane from 'ee/boards/components/epic_lane.vue'; import EpicLane from 'ee/boards/components/epic_lane.vue';
import EpicsSwimlanes from 'ee/boards/components/epics_swimlanes.vue'; import EpicsSwimlanes from 'ee/boards/components/epics_swimlanes.vue';
import IssueLaneList from 'ee/boards/components/issues_lane_list.vue'; import IssueLaneList from 'ee/boards/components/issues_lane_list.vue';
import { EPIC_LANE_BASE_HEIGHT } from 'ee/boards/constants';
import getters from 'ee/boards/stores/getters'; import getters from 'ee/boards/stores/getters';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { mockLists, mockEpics, mockIssuesByListId, issues } from '../mock_data'; import { mockLists, mockEpics, mockIssuesByListId, issues } from '../mock_data';
const localVue = createLocalVue(); Vue.use(Vuex);
localVue.use(Vuex); jest.mock('ee/boards/boards_util');
describe('EpicsSwimlanes', () => { describe('EpicsSwimlanes', () => {
let wrapper; let wrapper;
...@@ -38,7 +42,7 @@ describe('EpicsSwimlanes', () => { ...@@ -38,7 +42,7 @@ describe('EpicsSwimlanes', () => {
}); });
}; };
const createComponent = (props = {}) => { const createComponent = ({ canAdminList = false, swimlanesBufferedRendering = false } = {}) => {
const store = createStore(); const store = createStore();
const defaultProps = { const defaultProps = {
lists: mockLists, lists: mockLists,
...@@ -46,9 +50,11 @@ describe('EpicsSwimlanes', () => { ...@@ -46,9 +50,11 @@ describe('EpicsSwimlanes', () => {
}; };
wrapper = shallowMount(EpicsSwimlanes, { wrapper = shallowMount(EpicsSwimlanes, {
localVue, propsData: { ...defaultProps, canAdminList },
propsData: { ...defaultProps, ...props },
store, store,
provide: {
glFeatures: { swimlanesBufferedRendering },
},
}); });
}; };
...@@ -114,4 +120,30 @@ describe('EpicsSwimlanes', () => { ...@@ -114,4 +120,30 @@ describe('EpicsSwimlanes', () => {
).not.toContain('is-draggable'); ).not.toContain('is-draggable');
}); });
}); });
describe('when swimlanesBufferedRendering is true', () => {
const bufferSize = 100;
beforeEach(() => {
calculateSwimlanesBufferSize.mockReturnValueOnce(bufferSize);
createComponent({ swimlanesBufferedRendering: true });
});
it('renders virtual-list', () => {
const virtualList = wrapper.find(VirtualList);
const scrollableContainer = wrapper.find({ ref: 'scrollableContainer' }).element;
expect(calculateSwimlanesBufferSize).toHaveBeenCalledWith(wrapper.element.offsetTop);
expect(virtualList.props()).toMatchObject({
remain: bufferSize,
bench: bufferSize,
item: EpicLane,
size: EPIC_LANE_BASE_HEIGHT,
itemcount: mockEpics.length,
itemprops: expect.any(Function),
});
expect(virtualList.props().scrollelement).toBe(scrollableContainer);
});
});
}); });
...@@ -16,6 +16,15 @@ RSpec.describe Groups::BoardsController do ...@@ -16,6 +16,15 @@ RSpec.describe Groups::BoardsController do
expect { list_boards }.to change(group.boards, :count).by(1) expect { list_boards }.to change(group.boards, :count).by(1)
end end
it 'pushes swimlanes_buffered_rendering feature flag' do
allow(controller).to receive(:push_frontend_feature_flag).and_call_original
expect(controller).to receive(:push_frontend_feature_flag)
.with(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
list_boards
end
context 'when format is HTML' do context 'when format is HTML' do
it 'renders template' do it 'renders template' do
list_boards list_boards
...@@ -98,6 +107,15 @@ RSpec.describe Groups::BoardsController do ...@@ -98,6 +107,15 @@ RSpec.describe Groups::BoardsController do
describe 'GET show' do describe 'GET show' do
let!(:board) { create(:board, group: group) } let!(:board) { create(:board, group: group) }
it 'pushes swimlanes_buffered_rendering feature flag' do
allow(controller).to receive(:push_frontend_feature_flag).and_call_original
expect(controller).to receive(:push_frontend_feature_flag)
.with(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
read_board board: board
end
context 'when format is HTML' do context 'when format is HTML' do
it 'renders template' do it 'renders template' do
expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(1) expect { read_board board: board }.to change(BoardGroupRecentVisit, :count).by(1)
......
...@@ -22,6 +22,15 @@ RSpec.describe Projects::BoardsController do ...@@ -22,6 +22,15 @@ RSpec.describe Projects::BoardsController do
expect(assigns(:boards_endpoint)).to eq project_boards_path(project) expect(assigns(:boards_endpoint)).to eq project_boards_path(project)
end end
it 'pushes swimlanes_buffered_rendering feature flag' do
allow(controller).to receive(:push_frontend_feature_flag).and_call_original
expect(controller).to receive(:push_frontend_feature_flag)
.with(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
list_boards
end
context 'when format is HTML' do context 'when format is HTML' do
it 'renders template' do it 'renders template' do
list_boards list_boards
...@@ -116,6 +125,15 @@ RSpec.describe Projects::BoardsController do ...@@ -116,6 +125,15 @@ RSpec.describe Projects::BoardsController do
describe 'GET show' do describe 'GET show' do
let!(:board) { create(:board, project: project) } let!(:board) { create(:board, project: project) }
it 'pushes swimlanes_buffered_rendering feature flag' do
allow(controller).to receive(:push_frontend_feature_flag).and_call_original
expect(controller).to receive(:push_frontend_feature_flag)
.with(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
read_board board: board
end
it 'sets boards_endpoint instance variable to a boards path' do it 'sets boards_endpoint instance variable to a boards path' do
read_board board: board read_board board: board
......
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