Commit 61a0a919 authored by Simon Knox's avatar Simon Knox Committed by Jose Ivan Vargas

Highlight and scroll to board lists when added

Add 'highlighted' property to lists, and some styles to
the column when they are highlighted.
Haven't figured out reliable smooth scroll for swimlanes
since it is in the lane list it would scroll down a bit
Can maybe move it from issue_lane_list to epic_lane
parent 899aaa53
...@@ -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