Commit 911351b5 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'psi-board-list-highlight' into 'master'

[RUN-AS-IF-FOSS]Highlight and scroll to board lists when added

See merge request gitlab-org/gitlab!53779
parents c99670e5 61a0a919
......@@ -31,8 +31,11 @@ export default {
},
},
computed: {
...mapState(['filterParams']),
...mapState(['filterParams', 'highlightedLists']),
...mapGetters(['getIssuesByList']),
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
listIssues() {
return this.getIssuesByList(this.list.id);
},
......@@ -48,6 +51,16 @@ export default {
deep: true,
immediate: true,
},
highlighted: {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
},
immediate: true,
},
},
methods: {
...mapActions(['fetchIssuesForList']),
......@@ -68,6 +81,7 @@ export default {
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
:class="{ 'board-column-highlighted': highlighted }"
>
<board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
<board-list
......
......@@ -54,6 +54,16 @@ export default {
},
deep: true,
},
'list.highlighted': {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
},
immediate: true,
},
},
mounted() {
const instance = this;
......@@ -98,6 +108,7 @@ export default {
>
<div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
:class="{ 'board-column-highlighted': list.highlighted }"
>
<board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
<board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
......
......@@ -34,6 +34,8 @@ export const LIST = 'list';
export const NOT_FILTER = 'not[';
export const flashAnimationDuration = 2000;
export default {
BoardType,
ListType,
......
......@@ -44,6 +44,7 @@ class List {
this.isExpandable = Boolean(typeInfo.isExpandable);
this.isExpanded = !obj.collapsed;
this.page = 1;
this.highlighted = obj.highlighted;
this.loading = true;
this.loadingMore = false;
this.issues = obj.issues || [];
......
import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId } from '~/boards/constants';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
......@@ -110,9 +110,31 @@ export default {
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
},
createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => {
highlightList: ({ commit, state }, listId) => {
if ([ListType.backlog, ListType.closed].includes(state.boardLists[listId].listType)) {
return;
}
commit(types.ADD_LIST_TO_HIGHLIGHTED_LISTS, listId);
setTimeout(() => {
commit(types.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS, listId);
}, flashAnimationDuration);
},
createList: (
{ state, commit, dispatch, getters },
{ backlog, labelId, milestoneId, assigneeId },
) => {
const { boardId } = state;
const existingList = getters.getListByLabelId(labelId);
if (existingList) {
dispatch('highlightList', existingList.id);
return;
}
gqlClient
.mutate({
mutation: createBoardListMutation,
......@@ -130,6 +152,7 @@ export default {
} else {
const list = data.boardListCreate?.list;
dispatch('addList', list);
dispatch('highlightList', list.id);
}
})
.catch(() => commit(types.CREATE_LIST_FAILURE));
......
......@@ -14,7 +14,7 @@ import {
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { ListType } from '../constants';
import { ListType, flashAnimationDuration } from '../constants';
import eventHub from '../eventhub';
import ListAssignee from '../models/assignee';
import ListLabel from '../models/label';
......@@ -106,6 +106,11 @@ const boardsStore = {
list
.save()
.then(() => {
list.highlighted = true;
setTimeout(() => {
list.highlighted = false;
}, flashAnimationDuration);
// Remove any new issues from the backlog
// as they will be visible in the new list
list.issues.forEach(backlogList.removeIssue.bind(backlogList));
......
......@@ -28,6 +28,9 @@ export default {
},
getListByLabelId: (state) => (labelId) => {
if (!labelId) {
return null;
}
return find(state.boardLists, (l) => l.label?.id === labelId);
},
......
......@@ -43,3 +43,5 @@ export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT';
export const ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION';
export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTION';
export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
......@@ -274,4 +274,12 @@ export default {
[mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
state.addColumnFormVisible = visible;
},
[mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists.push(listId);
},
[mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => {
state.highlightedLists = state.highlightedLists.filter((id) => id !== listId);
},
};
......@@ -15,6 +15,7 @@ export default () => ({
filterParams: {},
boardConfig: {},
labels: [],
highlightedLists: [],
selectedBoardItems: [],
groupProjects: [],
groupProjectsFlags: {
......
......@@ -138,6 +138,47 @@
border: 1px solid var(--gray-100, $gray-100);
}
// to highlight columns we have animated pulse of box-shadow
// we don't want to actually animate the box-shadow property
// because that causes costly repaints. Instead we can add a
// pseudo-element that is the same size as our element, then
// animate opacity/transform to give a soothing single pulse
.board-column-highlighted::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
opacity: 0;
z-index: -1;
box-shadow: 0 0 6px 3px $blue-200;
animation-name: board-column-flash-border;
animation-duration: 1.2s;
animation-fill-mode: forwards;
animation-timing-function: ease-in-out;
}
@keyframes board-column-flash-border {
0%,
100% {
opacity: 0;
transform: scale(0.98);
}
25%,
75% {
opacity: 1;
transform: scale(0.99);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.board-header {
&.has-border::before {
border-top: 3px solid;
......
---
title: Highlight board lists when they are added
merge_request: 53779
author:
type: changed
......@@ -49,7 +49,7 @@ export default {
};
},
computed: {
...mapState(['activeId', 'filterParams', 'canAdminEpic', 'listsFlags']),
...mapState(['activeId', 'filterParams', 'canAdminEpic', 'listsFlags', 'highlightedLists']),
treeRootWrapper() {
return this.canAdminList && this.canAdminEpic ? Draggable : 'ul';
},
......@@ -70,6 +70,9 @@ export default {
isLoadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
},
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
},
watch: {
filterParams: {
......@@ -81,6 +84,16 @@ export default {
deep: true,
immediate: true,
},
highlighted: {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView(false);
});
}
},
immediate: true,
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
......@@ -163,6 +176,7 @@ export default {
v-if="!list.collapsed"
v-bind="treeRootOptions"
class="board-cell gl-p-2 gl-m-0 gl-h-full"
:class="{ 'board-column-highlighted': highlighted }"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import { mockList } from 'jest/boards/mock_data';
......@@ -116,5 +117,24 @@ describe('IssuesLaneList', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false);
});
});
describe('highlighting', () => {
it('scrolls to column when highlighted', async () => {
const defaultStore = createStore();
store = {
...defaultStore,
state: {
...defaultStore.state,
highlightedLists: [mockList.id],
},
};
createComponent();
await nextTick();
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
......@@ -30,6 +30,7 @@ describe('Board Column Component', () => {
const createComponent = ({
listType = ListType.backlog,
collapsed = false,
highlighted = false,
withLocalStorage = true,
} = {}) => {
const boardId = '1';
......@@ -37,6 +38,7 @@ describe('Board Column Component', () => {
const listMock = {
...listObj,
list_type: listType,
highlighted,
collapsed,
};
......@@ -91,4 +93,14 @@ describe('Board Column Component', () => {
expect(isCollapsed()).toBe(true);
});
});
describe('highlighting', () => {
it('scrolls to column when highlighted', async () => {
createComponent({ highlighted: true });
await nextTick();
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
});
});
});
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { listObj } from 'jest/boards/mock_data';
......@@ -66,4 +67,16 @@ describe('Board Column Component', () => {
expect(isCollapsed()).toBe(true);
});
});
describe('highlighting', () => {
it('scrolls to column when highlighted', async () => {
createComponent();
store.state.highlightedLists.push(listObj.id);
await nextTick();
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
});
});
});
......@@ -186,7 +186,27 @@ describe('fetchLists', () => {
});
describe('createList', () => {
it('should dispatch addList action when creating backlog list', (done) => {
let commit;
let dispatch;
let getters;
let state;
beforeEach(() => {
state = {
fullPath: 'gitlab-org',
boardId: '1',
boardType: 'group',
disabled: false,
boardLists: [{ type: 'closed' }],
};
commit = jest.fn();
dispatch = jest.fn();
getters = {
getListByLabelId: jest.fn(),
};
});
it('should dispatch addList action when creating backlog list', async () => {
const backlogList = {
id: 'gid://gitlab/List/1',
listType: 'backlog',
......@@ -205,25 +225,35 @@ describe('createList', () => {
}),
);
const state = {
fullPath: 'gitlab-org',
boardId: '1',
boardType: 'group',
disabled: false,
boardLists: [{ type: 'closed' }],
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
});
it('dispatches highlightList after addList has succeeded', async () => {
const list = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Open',
labelId: '4',
};
testAction(
actions.createList,
{ backlog: true },
state,
[],
[{ type: 'addList', payload: backlogList }],
done,
);
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
boardListCreate: {
list,
errors: [],
},
},
});
await actions.createList({ getters, state, commit, dispatch }, { labelId: '4' });
expect(dispatch).toHaveBeenCalledWith('addList', list);
expect(dispatch).toHaveBeenCalledWith('highlightList', list.id);
});
it('should commit CREATE_LIST_FAILURE mutation when API returns an error', (done) => {
it('should commit CREATE_LIST_FAILURE mutation when API returns an error', async () => {
jest.spyOn(gqlClient, 'mutate').mockReturnValue(
Promise.resolve({
data: {
......@@ -235,22 +265,28 @@ describe('createList', () => {
}),
);
const state = {
fullPath: 'gitlab-org',
boardId: '1',
boardType: 'group',
disabled: false,
boardLists: [{ type: 'closed' }],
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
});
it('highlights list and does not re-query if it already exists', async () => {
const existingList = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Some label',
position: 1,
};
testAction(
actions.createList,
{ backlog: true },
state,
[{ type: types.CREATE_LIST_FAILURE }],
[],
done,
);
getters = {
getListByLabelId: jest.fn().mockReturnValue(existingList),
};
await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
expect(dispatch).toHaveBeenCalledWith('highlightList', existingList.id);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(commit).not.toHaveBeenCalled();
});
});
......
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