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 { ...@@ -31,8 +31,11 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['filterParams']), ...mapState(['filterParams', 'highlightedLists']),
...mapGetters(['getIssuesByList']), ...mapGetters(['getIssuesByList']),
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
listIssues() { listIssues() {
return this.getIssuesByList(this.list.id); return this.getIssuesByList(this.list.id);
}, },
...@@ -48,6 +51,16 @@ export default { ...@@ -48,6 +51,16 @@ export default {
deep: true, deep: true,
immediate: true, immediate: true,
}, },
highlighted: {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
},
immediate: true,
},
}, },
methods: { methods: {
...mapActions(['fetchIssuesForList']), ...mapActions(['fetchIssuesForList']),
...@@ -68,6 +81,7 @@ export default { ...@@ -68,6 +81,7 @@ export default {
> >
<div <div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" 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-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
<board-list <board-list
......
...@@ -54,6 +54,16 @@ export default { ...@@ -54,6 +54,16 @@ export default {
}, },
deep: true, deep: true,
}, },
'list.highlighted': {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
},
immediate: true,
},
}, },
mounted() { mounted() {
const instance = this; const instance = this;
...@@ -98,6 +108,7 @@ export default { ...@@ -98,6 +108,7 @@ export default {
> >
<div <div
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" 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-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
<board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" /> <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
......
...@@ -34,6 +34,8 @@ export const LIST = 'list'; ...@@ -34,6 +34,8 @@ export const LIST = 'list';
export const NOT_FILTER = 'not['; export const NOT_FILTER = 'not[';
export const flashAnimationDuration = 2000;
export default { export default {
BoardType, BoardType,
ListType, ListType,
......
...@@ -44,6 +44,7 @@ class List { ...@@ -44,6 +44,7 @@ class List {
this.isExpandable = Boolean(typeInfo.isExpandable); this.isExpandable = Boolean(typeInfo.isExpandable);
this.isExpanded = !obj.collapsed; this.isExpanded = !obj.collapsed;
this.page = 1; this.page = 1;
this.highlighted = obj.highlighted;
this.loading = true; this.loading = true;
this.loadingMore = false; this.loadingMore = false;
this.issues = obj.issues || []; this.issues = obj.issues || [];
......
import { pick } from 'lodash'; import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; 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 createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql'; import createGqClient, { fetchPolicies } from '~/lib/graphql';
...@@ -110,9 +110,31 @@ export default { ...@@ -110,9 +110,31 @@ export default {
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); .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 { boardId } = state;
const existingList = getters.getListByLabelId(labelId);
if (existingList) {
dispatch('highlightList', existingList.id);
return;
}
gqlClient gqlClient
.mutate({ .mutate({
mutation: createBoardListMutation, mutation: createBoardListMutation,
...@@ -130,6 +152,7 @@ export default { ...@@ -130,6 +152,7 @@ export default {
} else { } else {
const list = data.boardListCreate?.list; const list = data.boardListCreate?.list;
dispatch('addList', list); dispatch('addList', list);
dispatch('highlightList', list.id);
} }
}) })
.catch(() => commit(types.CREATE_LIST_FAILURE)); .catch(() => commit(types.CREATE_LIST_FAILURE));
......
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
convertObjectPropsToCamelCase, convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import { ListType } from '../constants'; import { ListType, flashAnimationDuration } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ListAssignee from '../models/assignee'; import ListAssignee from '../models/assignee';
import ListLabel from '../models/label'; import ListLabel from '../models/label';
...@@ -106,6 +106,11 @@ const boardsStore = { ...@@ -106,6 +106,11 @@ const boardsStore = {
list list
.save() .save()
.then(() => { .then(() => {
list.highlighted = true;
setTimeout(() => {
list.highlighted = false;
}, flashAnimationDuration);
// Remove any new issues from the backlog // Remove any new issues from the backlog
// as they will be visible in the new list // as they will be visible in the new list
list.issues.forEach(backlogList.removeIssue.bind(backlogList)); list.issues.forEach(backlogList.removeIssue.bind(backlogList));
......
...@@ -28,6 +28,9 @@ export default { ...@@ -28,6 +28,9 @@ export default {
}, },
getListByLabelId: (state) => (labelId) => { getListByLabelId: (state) => (labelId) => {
if (!labelId) {
return null;
}
return find(state.boardLists, (l) => l.label?.id === labelId); return find(state.boardLists, (l) => l.label?.id === labelId);
}, },
......
...@@ -43,3 +43,5 @@ export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; ...@@ -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 ADD_BOARD_ITEM_TO_SELECTION = 'ADD_BOARD_ITEM_TO_SELECTION';
export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_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 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 { ...@@ -274,4 +274,12 @@ export default {
[mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => { [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => {
state.addColumnFormVisible = 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 () => ({ ...@@ -15,6 +15,7 @@ export default () => ({
filterParams: {}, filterParams: {},
boardConfig: {}, boardConfig: {},
labels: [], labels: [],
highlightedLists: [],
selectedBoardItems: [], selectedBoardItems: [],
groupProjects: [], groupProjects: [],
groupProjectsFlags: { groupProjectsFlags: {
......
...@@ -138,6 +138,47 @@ ...@@ -138,6 +138,47 @@
border: 1px solid var(--gray-100, $gray-100); 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 { .board-header {
&.has-border::before { &.has-border::before {
border-top: 3px solid; border-top: 3px solid;
......
---
title: Highlight board lists when they are added
merge_request: 53779
author:
type: changed
...@@ -49,7 +49,7 @@ export default { ...@@ -49,7 +49,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['activeId', 'filterParams', 'canAdminEpic', 'listsFlags']), ...mapState(['activeId', 'filterParams', 'canAdminEpic', 'listsFlags', 'highlightedLists']),
treeRootWrapper() { treeRootWrapper() {
return this.canAdminList && this.canAdminEpic ? Draggable : 'ul'; return this.canAdminList && this.canAdminEpic ? Draggable : 'ul';
}, },
...@@ -70,6 +70,9 @@ export default { ...@@ -70,6 +70,9 @@ export default {
isLoadingMore() { isLoadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore; return this.listsFlags[this.list.id]?.isLoadingMore;
}, },
highlighted() {
return this.highlightedLists.includes(this.list.id);
},
}, },
watch: { watch: {
filterParams: { filterParams: {
...@@ -81,6 +84,16 @@ export default { ...@@ -81,6 +84,16 @@ export default {
deep: true, deep: true,
immediate: true, immediate: true,
}, },
highlighted: {
handler(highlighted) {
if (highlighted) {
this.$nextTick(() => {
this.$el.scrollIntoView(false);
});
}
},
immediate: true,
},
}, },
created() { created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
...@@ -163,6 +176,7 @@ export default { ...@@ -163,6 +176,7 @@ export default {
v-if="!list.collapsed" v-if="!list.collapsed"
v-bind="treeRootOptions" v-bind="treeRootOptions"
class="board-cell gl-p-2 gl-m-0 gl-h-full" class="board-cell gl-p-2 gl-m-0 gl-h-full"
:class="{ 'board-column-highlighted': highlighted }"
data-testid="tree-root-wrapper" data-testid="tree-root-wrapper"
@start="handleDragOnStart" @start="handleDragOnStart"
@end="handleDragOnEnd" @end="handleDragOnEnd"
......
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue'; import IssuesLaneList from 'ee/boards/components/issues_lane_list.vue';
import { mockList } from 'jest/boards/mock_data'; import { mockList } from 'jest/boards/mock_data';
...@@ -116,5 +117,24 @@ describe('IssuesLaneList', () => { ...@@ -116,5 +117,24 @@ describe('IssuesLaneList', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false); 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 { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue'; import Vue, { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
...@@ -30,6 +30,7 @@ describe('Board Column Component', () => { ...@@ -30,6 +30,7 @@ describe('Board Column Component', () => {
const createComponent = ({ const createComponent = ({
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
highlighted = false,
withLocalStorage = true, withLocalStorage = true,
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
...@@ -37,6 +38,7 @@ describe('Board Column Component', () => { ...@@ -37,6 +38,7 @@ describe('Board Column Component', () => {
const listMock = { const listMock = {
...listObj, ...listObj,
list_type: listType, list_type: listType,
highlighted,
collapsed, collapsed,
}; };
...@@ -91,4 +93,14 @@ describe('Board Column Component', () => { ...@@ -91,4 +93,14 @@ describe('Board Column Component', () => {
expect(isCollapsed()).toBe(true); 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 { shallowMount } from '@vue/test-utils';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
...@@ -66,4 +67,16 @@ describe('Board Column Component', () => { ...@@ -66,4 +67,16 @@ describe('Board Column Component', () => {
expect(isCollapsed()).toBe(true); 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', () => { ...@@ -186,7 +186,27 @@ describe('fetchLists', () => {
}); });
describe('createList', () => { 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 = { const backlogList = {
id: 'gid://gitlab/List/1', id: 'gid://gitlab/List/1',
listType: 'backlog', listType: 'backlog',
...@@ -205,25 +225,35 @@ describe('createList', () => { ...@@ -205,25 +225,35 @@ describe('createList', () => {
}), }),
); );
const state = { await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
fullPath: 'gitlab-org',
boardId: '1', expect(dispatch).toHaveBeenCalledWith('addList', backlogList);
boardType: 'group', });
disabled: false,
boardLists: [{ type: 'closed' }], it('dispatches highlightList after addList has succeeded', async () => {
const list = {
id: 'gid://gitlab/List/1',
listType: 'label',
title: 'Open',
labelId: '4',
}; };
testAction( jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
actions.createList, data: {
{ backlog: true }, boardListCreate: {
state, list,
[], errors: [],
[{ type: 'addList', payload: backlogList }], },
done, },
); });
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( jest.spyOn(gqlClient, 'mutate').mockReturnValue(
Promise.resolve({ Promise.resolve({
data: { data: {
...@@ -235,22 +265,28 @@ describe('createList', () => { ...@@ -235,22 +265,28 @@ describe('createList', () => {
}), }),
); );
const state = { await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
fullPath: 'gitlab-org',
boardId: '1', expect(commit).toHaveBeenCalledWith(types.CREATE_LIST_FAILURE);
boardType: 'group', });
disabled: false,
boardLists: [{ type: 'closed' }], 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( getters = {
actions.createList, getListByLabelId: jest.fn().mockReturnValue(existingList),
{ backlog: true }, };
state,
[{ type: types.CREATE_LIST_FAILURE }], await actions.createList({ getters, state, commit, dispatch }, { backlog: true });
[],
done, 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