Commit 0daff5ee authored by Simon Knox's avatar Simon Knox

Merge branch 'feature/prevent-boards-store-create-graphql-boards' into 'master'

Boards - Prevent boardsStore creation on graphQL boards

See merge request gitlab-org/gitlab!51424
parents 1622ac99 6ae76691
<script> <script>
// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards import { mapGetters, mapActions, mapState } from 'vuex';
import Sortable from 'sortablejs';
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 BoardList from './board_list.vue'; import BoardList from './board_list.vue';
import boardsStore from '../stores/boards_store'; import { isListDraggable } from '../boards_util';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
export default { export default {
components: { components: {
...@@ -32,53 +30,27 @@ export default { ...@@ -32,53 +30,27 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
detailIssue: boardsStore.detail,
filter: boardsStore.filter,
};
},
computed: { computed: {
...mapState(['filterParams']),
...mapGetters(['getIssuesByList']),
listIssues() { listIssues() {
return this.list.issues; return this.getIssuesByList(this.list.id);
},
isListDraggable() {
return isListDraggable(this.list);
}, },
}, },
watch: { watch: {
filter: { filterParams: {
handler() { handler() {
this.list.page = 1; this.fetchIssuesForList({ listId: this.list.id });
this.list.getIssues(true).catch(() => {
// TODO: handle request error
});
}, },
deep: true, deep: true,
immediate: true,
}, },
}, },
mounted() { methods: {
const instance = this; ...mapActions(['fetchIssuesForList']),
const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
onEnd(e) {
sortableEnd();
const sortable = this;
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
const order = sortable.toArray();
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
instance.$nextTick(() => {
boardsStore.moveList(list, order);
});
}
},
});
Sortable.create(this.$el.parentNode, sortableOptions);
}, },
}; };
</script> </script>
...@@ -86,20 +58,25 @@ export default { ...@@ -86,20 +58,25 @@ export default {
<template> <template>
<div <div
:class="{ :class="{
'is-draggable': !list.preset, 'is-draggable': isListDraggable,
'is-expandable': list.isExpandable, 'is-collapsed': list.collapsed,
'is-collapsed': !list.isExpanded, 'board-type-assignee': list.listType === 'assignee',
'board-type-assignee': list.type === 'assignee',
}" }"
:data-id="list.id" :data-id="list.id"
class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable"
data-qa-selector="board_list" data-qa-selector="board_list"
> >
<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"
> >
<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"
:can-admin-list="canAdminList"
/>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters, mapActions, mapState } from 'vuex'; // This component is being replaced in favor of './board_column.vue' for GraphQL boards
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; import Sortable from 'sortablejs';
import BoardList from './board_list_new.vue'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
import { isListDraggable } from '../boards_util'; import BoardList from './board_list_deprecated.vue';
import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
export default { export default {
components: { components: {
...@@ -30,27 +32,53 @@ export default { ...@@ -30,27 +32,53 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
detailIssue: boardsStore.detail,
filter: boardsStore.filter,
};
},
computed: { computed: {
...mapState(['filterParams']),
...mapGetters(['getIssuesByList']),
listIssues() { listIssues() {
return this.getIssuesByList(this.list.id); return this.list.issues;
},
isListDraggable() {
return isListDraggable(this.list);
}, },
}, },
watch: { watch: {
filterParams: { filter: {
handler() { handler() {
this.fetchIssuesForList({ listId: this.list.id }); this.list.page = 1;
this.list.getIssues(true).catch(() => {
// TODO: handle request error
});
}, },
deep: true, deep: true,
immediate: true,
}, },
}, },
methods: { mounted() {
...mapActions(['fetchIssuesForList']), const instance = this;
const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
onEnd(e) {
sortableEnd();
const sortable = this;
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
const order = sortable.toArray();
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
instance.$nextTick(() => {
boardsStore.moveList(list, order);
});
}
},
});
Sortable.create(this.$el.parentNode, sortableOptions);
}, },
}; };
</script> </script>
...@@ -58,25 +86,20 @@ export default { ...@@ -58,25 +86,20 @@ export default {
<template> <template>
<div <div
:class="{ :class="{
'is-draggable': isListDraggable, 'is-draggable': !list.preset,
'is-collapsed': list.collapsed, 'is-expandable': list.isExpandable,
'board-type-assignee': list.listType === 'assignee', 'is-collapsed': !list.isExpanded,
'board-type-assignee': list.type === 'assignee',
}" }"
:data-id="list.id" :data-id="list.id"
class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
data-qa-selector="board_list" data-qa-selector="board_list"
> >
<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"
> >
<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 ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
ref="board-list"
:disabled="disabled"
:issues="listIssues"
:list="list"
:can-admin-list="canAdminList"
/>
</div> </div>
</div> </div>
</template> </template>
...@@ -3,15 +3,15 @@ import Draggable from 'vuedraggable'; ...@@ -3,15 +3,15 @@ import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import BoardColumnDeprecated from './board_column_deprecated.vue';
import BoardColumn from './board_column.vue'; import BoardColumn from './board_column.vue';
import BoardColumnNew from './board_column_new.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
export default { export default {
components: { components: {
BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn, BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated,
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert, GlAlert,
...@@ -20,7 +20,8 @@ export default { ...@@ -20,7 +20,8 @@ export default {
props: { props: {
lists: { lists: {
type: Array, type: Array,
required: true, required: false,
default: () => [],
}, },
canAdminList: { canAdminList: {
type: Boolean, type: Boolean,
...@@ -53,7 +54,7 @@ export default { ...@@ -53,7 +54,7 @@ export default {
fallbackOnBody: false, fallbackOnBody: false,
group: 'boards-list', group: 'boards-list',
tag: 'div', tag: 'div',
value: this.lists, value: this.boardListsToUse,
}; };
return this.canDragColumns ? options : {}; return this.canDragColumns ? options : {};
......
<script> <script>
import { Sortable, MultiDrag } from 'sortablejs'; import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import boardNewIssue from './board_new_issue.vue'; import defaultSortableConfig from '~/sortable/sortable_config';
import boardCard from './board_card.vue'; import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import BoardNewIssue from './board_new_issue.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards
Sortable.mount(new MultiDrag());
export default { export default {
name: 'BoardList', name: 'BoardList',
i18n: {
loadingIssues: __('Loading issues'),
loadingMoreissues: __('Loading more issues'),
showingAllIssues: __('Showing all issues'),
},
components: { components: {
boardCard, BoardCard,
boardNewIssue, BoardNewIssue,
GlLoadingIcon, GlLoadingIcon,
}, },
props: { props: {
...@@ -37,55 +34,67 @@ export default { ...@@ -37,55 +34,67 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
canAdminList: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
scrollOffset: 250, scrollOffset: 250,
filters: boardsStore.state.filters,
showCount: false, showCount: false,
showIssueForm: false, showIssueForm: false,
}; };
}, },
computed: { computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
paginatedIssueText() { paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), { return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length, pageSize: this.issues.length,
total: this.list.issuesSize, total: this.list.issuesCount,
}); });
}, },
issuesSizeExceedsMax() { issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
}, },
loading() { loading() {
return this.list.loading; return this.listsFlags[this.list.id]?.isLoading;
},
loadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
showingAllIssues() {
return this.issues.length === this.list.issuesCount;
},
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
},
treeRootOptions() {
const options = {
...defaultSortableConfig,
fallbackOnBody: false,
group: 'board-list',
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
value: this.issues,
};
return this.canAdminList ? options : {};
}, },
}, },
watch: { watch: {
filters: {
handler() {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
deep: true,
},
issues() { issues() {
this.$nextTick(() => { this.$nextTick(() => {
if ( this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length &&
this.list.isExpanded
) {
this.list.page += 1;
this.list.getIssues(false).catch(() => {
// TODO: handle request error
});
}
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.showCount = true;
} else {
this.showCount = false;
}
}); });
}, },
}, },
...@@ -94,315 +103,90 @@ export default { ...@@ -94,315 +103,90 @@ export default {
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
}, },
mounted() { mounted() {
// TODO: Use Draggable in ./board_list_new.vue to drag & drop issue
// https://gitlab.com/gitlab-org/gitlab/-/issues/218164
const multiSelectOpts = {
multiDrag: true,
selectedClass: 'js-multi-select',
animation: 500,
};
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: {
name: 'issues',
/**
* Dynamically determine between which containers
* items can be moved or copied as
* Assignee lists (EE feature) require this behavior
*/
pull: (to, from, dragEl, e) => {
// As per Sortable's docs, `to` should provide
// reference to exact sortable container on which
// we're trying to drag element, but either it is
// a library's bug or our markup structure is too complex
// that `to` never points to correct container
// See https://github.com/RubaXa/Sortable/issues/1037
//
// So we use `e.target` which is always accurate about
// which element we're currently dragging our card upon
// So from there, we can get reference to actual container
// and thus the container type to enable Copy or Move
if (e.target) {
const containerEl =
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
label: ['milestone', 'assignee'],
assignee: ['milestone', 'label'],
milestone: ['label', 'assignee'],
};
if (toBoardType) {
const fromBoardType = this.list.type;
// For each list we check if the destination list is
// a the list were we should clone the issue
const shouldClone = Object.entries(cloneActions).some(
(entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
);
if (shouldClone) {
return 'clone';
}
}
}
return true;
},
revertClone: true,
},
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
card.showDetail = false;
const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue);
this.$root.$emit('bv::hide::tooltip');
sortableStart();
},
onAdd: (e) => {
const { items = [], newIndicies = [] } = e;
if (items.length) {
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// a drop what is the index we're going to start putting
// all the dropped elements from.
const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesToList({
listFrom: boardsStore.moving.list,
listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
},
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map((obj) => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach((el) => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
e.oldIndex,
e.newIndex,
sortedArray,
);
},
onEnd: (e) => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board), '');
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
if (
issues.filter(Boolean).length &&
!boardsStore.issuesAreContiguous(this.list, issues)
) {
const indexes = [];
const ids = this.list.issues.map((i) => i.id);
issues.forEach((issue) => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach((i) => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash(__('Something went wrong while performing the action.'));
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach((el) => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach((item) => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: (e) => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
});
this.sortable = Sortable.create(this.$refs.list, options);
// Scroll event on list to load more // Scroll event on list to load more
this.$refs.list.addEventListener('scroll', this.onScroll); this.listRef.addEventListener('scroll', this.onScroll);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll); this.listRef.removeEventListener('scroll', this.onScroll);
}, },
methods: { methods: {
...mapActions(['fetchIssuesForList', 'moveIssue']),
listHeight() { listHeight() {
return this.$refs.list.getBoundingClientRect().height; return this.listRef.getBoundingClientRect().height;
}, },
scrollHeight() { scrollHeight() {
return this.$refs.list.scrollHeight; return this.listRef.scrollHeight;
}, },
scrollTop() { scrollTop() {
return this.$refs.list.scrollTop + this.listHeight(); return this.listRef.scrollTop + this.listHeight();
}, },
scrollToTop() { scrollToTop() {
this.$refs.list.scrollTop = 0; this.listRef.scrollTop = 0;
}, },
loadNextPage() { loadNextPage() {
const getIssues = this.list.nextPage(); this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
const loadingDone = () => {
this.list.loadingMore = false;
};
if (getIssues) {
this.list.loadingMore = true;
getIssues.then(loadingDone).catch(loadingDone);
}
}, },
toggleForm() { toggleForm() {
this.showIssueForm = !this.showIssueForm; this.showIssueForm = !this.showIssueForm;
}, },
onScroll() { onScroll() {
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { window.requestAnimationFrame(() => {
this.loadNextPage(); if (
!this.loadingMore &&
this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
this.hasNextPage
) {
this.loadNextPage();
}
});
},
handleDragOnStart() {
sortableStart();
},
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
const getIssueId = (el) => Number(el.dataset.issueId);
// If issue is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before
moveBeforeId = getIssueId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after
moveAfterId = getIssueId(children[newIndex]);
} else {
// If issue remains in the same list at the same position we do nothing
return;
}
} else {
// We look for the issue that ends up before the moved issue if it exists
if (children[newIndex - 1]) {
moveBeforeId = getIssueId(children[newIndex - 1]);
}
// We look for the issue that ends up after the moved issue if it exists
if (children[newIndex]) {
moveAfterId = getIssueId(children[newIndex]);
}
} }
this.moveIssue({
issueId,
issueIid,
issuePath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
moveAfterId,
});
}, },
}, },
}; };
...@@ -410,21 +194,31 @@ export default { ...@@ -410,21 +194,31 @@ export default {
<template> <template>
<div <div
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" v-show="!list.collapsed"
class="board-list-component position-relative h-100" class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
data-qa-selector="board_list_cards_area" data-qa-selector="board_list_cards_area"
> >
<div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> <div
v-if="loading"
class="gl-mt-4 gl-text-center"
:aria-label="$options.i18n.loadingIssues"
data-testid="board_list_loading"
>
<gl-loading-icon /> <gl-loading-icon />
</div> </div>
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
<ul <component
:is="treeRootWrapper"
v-show="!loading" v-show="!loading"
ref="list" ref="list"
v-bind="treeRootOptions"
:data-board="list.id" :data-board="list.id"
:data-board-type="list.type" :data-board-type="list.listType"
:class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }" :class="{ 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
> >
<board-card <board-card
v-for="(issue, index) in issues" v-for="(issue, index) in issues"
...@@ -435,11 +229,11 @@ export default { ...@@ -435,11 +229,11 @@ export default {
:issue="issue" :issue="issue"
:disabled="disabled" :disabled="disabled"
/> />
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span> <span v-else>{{ paginatedIssueText }}</span>
</li> </li>
</ul> </component>
</div> </div>
</template> </template>
<script>
import { Sortable, MultiDrag } from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
import boardNewIssue from './board_new_issue_deprecated.vue';
import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import {
getBoardSortableDefaultOptions,
sortableStart,
sortableEnd,
} from '../mixins/sortable_default_options';
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
Sortable.mount(new MultiDrag());
export default {
name: 'BoardList',
components: {
boardCard,
boardNewIssue,
GlLoadingIcon,
},
props: {
disabled: {
type: Boolean,
required: true,
},
list: {
type: Object,
required: true,
},
issues: {
type: Array,
required: true,
},
},
data() {
return {
scrollOffset: 250,
filters: boardsStore.state.filters,
showCount: false,
showIssueForm: false,
};
},
computed: {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.list.issues.length,
total: this.list.issuesSize,
});
},
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
},
loading() {
return this.list.loading;
},
},
watch: {
filters: {
handler() {
this.list.loadingMore = false;
this.$refs.list.scrollTop = 0;
},
deep: true,
},
issues() {
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
this.list.issuesSize > this.list.issues.length &&
this.list.isExpanded
) {
this.list.page += 1;
this.list.getIssues(false).catch(() => {
// TODO: handle request error
});
}
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
this.showCount = true;
} else {
this.showCount = false;
}
});
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const multiSelectOpts = {
multiDrag: true,
selectedClass: 'js-multi-select',
animation: 500,
};
const options = getBoardSortableDefaultOptions({
scroll: true,
disabled: this.disabled,
filter: '.board-list-count, .is-disabled',
dataIdAttr: 'data-issue-id',
removeCloneOnHide: false,
...multiSelectOpts,
group: {
name: 'issues',
/**
* Dynamically determine between which containers
* items can be moved or copied as
* Assignee lists (EE feature) require this behavior
*/
pull: (to, from, dragEl, e) => {
// As per Sortable's docs, `to` should provide
// reference to exact sortable container on which
// we're trying to drag element, but either it is
// a library's bug or our markup structure is too complex
// that `to` never points to correct container
// See https://github.com/RubaXa/Sortable/issues/1037
//
// So we use `e.target` which is always accurate about
// which element we're currently dragging our card upon
// So from there, we can get reference to actual container
// and thus the container type to enable Copy or Move
if (e.target) {
const containerEl =
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
label: ['milestone', 'assignee'],
assignee: ['milestone', 'label'],
milestone: ['label', 'assignee'],
};
if (toBoardType) {
const fromBoardType = this.list.type;
// For each list we check if the destination list is
// a the list were we should clone the issue
const shouldClone = Object.entries(cloneActions).some(
(entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
);
if (shouldClone) {
return 'clone';
}
}
}
return true;
},
revertClone: true,
},
onStart: (e) => {
const card = this.$refs.issue[e.oldIndex];
card.showDetail = false;
const { list } = card;
const issue = list.findIssue(Number(e.item.dataset.issueId));
boardsStore.startMoving(list, issue);
this.$root.$emit('bv::hide::tooltip');
sortableStart();
},
onAdd: (e) => {
const { items = [], newIndicies = [] } = e;
if (items.length) {
// Not using e.newIndex here instead taking a min of all
// the newIndicies. Basically we have to find that during
// a drop what is the index we're going to start putting
// all the dropped elements from.
const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesToList({
listFrom: boardsStore.moving.list,
listTo: this.list,
issues,
newIndex,
});
} else {
boardsStore.moveIssueToList(
boardsStore.moving.list,
this.list,
boardsStore.moving.issue,
e.newIndex,
);
this.$nextTick(() => {
e.item.remove();
});
}
},
onUpdate: (e) => {
const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
const { items = [], newIndicies = [], oldIndicies = [] } = e;
if (items.length) {
const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
const issues = items.map((item) =>
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
);
boardsStore.moveMultipleIssuesInList({
list: this.list,
issues,
oldIndicies: oldIndicies.map((obj) => obj.index),
newIndex,
idArray: sortedArray,
});
e.items.forEach((el) => {
Sortable.utils.deselect(el);
});
boardsStore.clearMultiSelect();
return;
}
boardsStore.moveIssueInList(
this.list,
boardsStore.moving.issue,
e.oldIndex,
e.newIndex,
sortedArray,
);
},
onEnd: (e) => {
const { items = [], clones = [], to } = e;
// This is not a multi select operation
if (!items.length && !clones.length) {
sortableEnd();
return;
}
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
toList = boardsStore.findList('id', Number(containerEl.dataset.board), '');
}
/**
* onEnd is called irrespective if the cards were moved in the
* same list or the other list. Don't remove items if it's same list.
*/
const isSameList = toList && toList.id === this.list.id;
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
if (
issues.filter(Boolean).length &&
!boardsStore.issuesAreContiguous(this.list, issues)
) {
const indexes = [];
const ids = this.list.issues.map((i) => i.id);
issues.forEach((issue) => {
const index = ids.indexOf(issue.id);
if (index > -1) {
indexes.push(index);
}
});
// Descending sort because splice would cause index discrepancy otherwise
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
sortedIndexes.forEach((i) => {
/**
* **setTimeout and splice each element one-by-one in a loop
* is intended.**
*
* The problem here is all the indexes are in the list but are
* non-contiguous. Due to that, when we splice all the indexes,
* at once, Vue -- during a re-render -- is unable to find reference
* nodes and the entire app crashes.
*
* If the indexes are contiguous, this piece of code is not
* executed. If it is, this is a possible regression. Only when
* issue indexes are far apart, this logic should ever kick in.
*/
setTimeout(() => {
this.list.issues.splice(i, 1);
}, 0);
});
}
}
if (!toList) {
createFlash(__('Something went wrong while performing the action.'));
}
if (!isSameList) {
boardsStore.clearMultiSelect();
// Since Vue's list does not re-render the same keyed item, we'll
// remove `multi-select` class to express it's unselected
if (clones && clones.length) {
clones.forEach((el) => el.classList.remove('multi-select'));
}
// Due to some bug which I am unable to figure out
// Sortable does not deselect some pending items from the
// source list.
// We'll just do it forcefully here.
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
Sortable.utils.deselect(item);
});
/**
* SortableJS leaves all the moving items "as is" on the DOM.
* Vue picks up and rehydrates the DOM, but we need to explicity
* remove the "trash" items from the DOM.
*
* This is in parity to the logic on single item move from a list/in
* a list. For reference, look at the implementation of onAdd method.
*/
this.$nextTick(() => {
if (items && items.length) {
items.forEach((item) => {
item.remove();
});
}
});
}
sortableEnd();
},
onMove(e) {
return !e.related.classList.contains('board-list-count');
},
onSelect(e) {
const {
item: { classList },
} = e;
if (
classList &&
classList.contains('js-multi-select') &&
!classList.contains('multi-select')
) {
Sortable.utils.deselect(e.item);
}
},
onDeselect: (e) => {
const {
item: { dataset, classList },
} = e;
if (
classList &&
classList.contains('multi-select') &&
!classList.contains('js-multi-select')
) {
const issue = this.list.findIssue(Number(dataset.issueId));
boardsStore.toggleMultiSelect(issue);
}
},
});
this.sortable = Sortable.create(this.$refs.list, options);
// Scroll event on list to load more
this.$refs.list.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
methods: {
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
scrollToTop() {
this.$refs.list.scrollTop = 0;
},
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
this.list.loadingMore = false;
};
if (getIssues) {
this.list.loadingMore = true;
getIssues.then(loadingDone).catch(loadingDone);
}
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
this.loadNextPage();
}
},
},
};
</script>
<template>
<div
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
class="board-list-component position-relative h-100"
data-qa-selector="board_list_cards_area"
>
<div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
<gl-loading-icon />
</div>
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
<ul
v-show="!loading"
ref="list"
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
>
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</ul>
</div>
</template>
...@@ -9,16 +9,22 @@ import { ...@@ -9,16 +9,22 @@ import {
GlSprintf, GlSprintf,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { n__, s__ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import IssueCount from './issue_count.vue'; import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
import { inactiveId, LIST, ListType } from '../constants'; import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel } from '~/lib/utils/common_utils';
import { isListDraggable } from '~/boards/boards_util';
export default { export default {
i18n: {
newIssue: __('New issue'),
listSettings: __('List settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
},
components: { components: {
GlButtonGroup, GlButtonGroup,
GlButton, GlButton,
...@@ -35,6 +41,15 @@ export default { ...@@ -35,6 +41,15 @@ export default {
boardId: { boardId: {
default: '', default: '',
}, },
weightFeatureAvailable: {
default: false,
},
scopedLabelsAvailable: {
default: false,
},
currentUserId: {
default: null,
},
}, },
props: { props: {
list: { list: {
...@@ -52,56 +67,53 @@ export default { ...@@ -52,56 +67,53 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
weightFeatureAvailable: false,
};
},
computed: { computed: {
...mapState(['activeId']), ...mapState(['activeId']),
isLoggedIn() { isLoggedIn() {
return Boolean(gon.current_user_id); return Boolean(this.currentUserId);
}, },
listType() { listType() {
return this.list.type; return this.list.listType;
}, },
listAssignee() { listAssignee() {
return this.list?.assignee?.username || ''; return this.list?.assignee?.username || '';
}, },
listTitle() { listTitle() {
return this.list?.label?.description || this.list.title || ''; return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
}, },
showListHeaderButton() { showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed; return !this.disabled && this.listType !== ListType.closed;
}, },
showMilestoneListDetails() { showMilestoneListDetails() {
return ( return (
this.list.type === 'milestone' && this.listType === ListType.milestone &&
this.list.milestone && this.list.milestone &&
(this.list.isExpanded || !this.isSwimlanesHeader) (!this.list.collapsed || !this.isSwimlanesHeader)
); );
}, },
showAssigneeListDetails() { showAssigneeListDetails() {
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); return (
this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
);
}, },
issuesCount() { issuesCount() {
return this.list.issuesSize; return this.list.issuesCount;
}, },
issuesTooltipLabel() { issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount); return n__(`%d issue`, `%d issues`, this.issuesCount);
}, },
chevronTooltip() { chevronTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
}, },
chevronIcon() { chevronIcon() {
return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; return this.list.collapsed ? 'chevron-down' : 'chevron-right';
}, },
isNewIssueShown() { isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton; return this.listType === ListType.backlog || this.showListHeaderButton;
}, },
isSettingsShown() { isSettingsShown() {
return ( return (
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
); );
}, },
uniqueKey() { uniqueKey() {
...@@ -111,9 +123,15 @@ export default { ...@@ -111,9 +123,15 @@ export default {
collapsedTooltipTitle() { collapsedTooltipTitle() {
return this.listTitle || this.listAssignee; return this.listTitle || this.listAssignee;
}, },
headerStyle() {
return { borderTopColor: this.list?.label?.color };
},
userCanDrag() {
return !this.disabled && isListDraggable(this.list);
},
}, },
methods: { methods: {
...mapActions(['setActiveId']), ...mapActions(['updateList', 'setActiveId']),
openSidebarSettings() { openSidebarSettings() {
if (this.activeId === inactiveId) { if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll'); sidebarEventHub.$emit('sidebar.closeAll');
...@@ -122,14 +140,14 @@ export default { ...@@ -122,14 +140,14 @@ export default {
this.setActiveId({ id: this.list.id, sidebarType: LIST }); this.setActiveId({ id: this.list.id, sidebarType: LIST });
}, },
showScopedLabels(label) { showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label); return this.scopedLabelsAvailable && isScopedLabel(label);
}, },
showNewIssueForm() { showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`toggle-issue-form-${this.list.id}`);
}, },
toggleExpanded() { toggleExpanded() {
this.list.isExpanded = !this.list.isExpanded; this.list.collapsed = !this.list.collapsed;
if (!this.isLoggedIn) { if (!this.isLoggedIn) {
this.addToLocalStorage(); this.addToLocalStorage();
...@@ -143,11 +161,11 @@ export default { ...@@ -143,11 +161,11 @@ export default {
}, },
addToLocalStorage() { addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) { if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
} }
}, },
updateListFunction() { updateListFunction() {
this.list.update(); this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
}, },
}, },
}; };
...@@ -157,26 +175,25 @@ export default { ...@@ -157,26 +175,25 @@ export default {
<header <header
:class="{ :class="{
'has-border': list.label && list.label.color, 'has-border': list.label && list.label.color,
'gl-h-full': !list.isExpanded, 'gl-h-full': list.collapsed,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}" }"
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }" :style="headerStyle"
class="board-header gl-relative" class="board-header gl-relative"
data-qa-selector="board_list_header" data-qa-selector="board_list_header"
data-testid="board-list-header" data-testid="board-list-header"
> >
<h3 <h3
:class="{ :class="{
'user-can-drag': !disabled && !list.preset, 'user-can-drag': userCanDrag,
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, 'gl-border-b-0': list.collapsed || isSwimlanesHeader,
'gl-py-2': !list.isExpanded && isSwimlanesHeader, 'gl-py-2': list.collapsed && isSwimlanesHeader,
'gl-flex-direction-column': !list.isExpanded, 'gl-flex-direction-column': list.collapsed,
}" }"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
> >
<gl-button <gl-button
v-if="list.isExpandable"
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="chevronTooltip" :aria-label="chevronTooltip"
:title="chevronTooltip" :title="chevronTooltip"
...@@ -186,14 +203,14 @@ export default { ...@@ -186,14 +203,14 @@ export default {
size="small" size="small"
@click="toggleExpanded" @click="toggleExpanded"
/> />
<!-- The following is only true in EE and if it is a milestone --> <!-- EE start -->
<span <span
v-if="showMilestoneListDetails" v-if="showMilestoneListDetails"
aria-hidden="true" aria-hidden="true"
class="milestone-icon" class="milestone-icon"
:class="{ :class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded, 'gl-mt-3 gl-rotate-90': list.collapsed,
'gl-mr-2': list.isExpanded, 'gl-mr-2': !list.collapsed,
}" }"
> >
<gl-icon name="timer" /> <gl-icon name="timer" />
...@@ -201,90 +218,95 @@ export default { ...@@ -201,90 +218,95 @@ export default {
<a <a
v-if="showAssigneeListDetails" v-if="showAssigneeListDetails"
:href="list.assignee.path" :href="list.assignee.webUrl"
class="user-avatar-link js-no-trigger" class="user-avatar-link js-no-trigger"
:class="{ :class="{
'gl-mt-3 gl-rotate-90': !list.isExpanded, 'gl-mt-3 gl-rotate-90': list.collapsed,
}" }"
> >
<img <img
v-gl-tooltip.hover.bottom v-gl-tooltip.hover.bottom
:title="listAssignee" :title="listAssignee"
:alt="list.assignee.name" :alt="list.assignee.name"
:src="list.assignee.avatar" :src="list.assignee.avatarUrl"
class="avatar s20" class="avatar s20"
height="20" height="20"
width="20" width="20"
/> />
</a> </a>
<!-- EE end -->
<div <div
class="board-title-text" class="board-title-text"
:class="{ :class="{
'gl-display-none': !list.isExpanded && isSwimlanesHeader, 'gl-display-none': list.collapsed && isSwimlanesHeader,
'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
'gl-flex-grow-1': list.isExpanded, 'gl-flex-grow-1': !list.collapsed,
}" }"
> >
<!-- EE start -->
<span <span
v-if="list.type !== 'label'" v-if="listType !== 'label'"
v-gl-tooltip.hover v-gl-tooltip.hover
:class="{ :class="{
'gl-display-block': !list.isExpanded || list.type === 'milestone', 'gl-display-block': list.collapsed || listType === 'milestone',
}" }"
:title="listTitle" :title="listTitle"
class="board-title-main-text gl-text-truncate" class="board-title-main-text gl-text-truncate"
> >
{{ list.title }} {{ listTitle }}
</span> </span>
<span <span
v-if="list.type === 'assignee'" v-if="listType === 'assignee'"
v-show="!list.collapsed"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
:class="{ 'gl-display-none': !list.isExpanded }"
> >
@{{ listAssignee }} @{{ listAssignee }}
</span> </span>
<!-- EE end -->
<gl-label <gl-label
v-if="list.type === 'label'" v-if="listType === 'label'"
v-gl-tooltip.hover.bottom v-gl-tooltip.hover.bottom
:background-color="list.label.color" :background-color="list.label.color"
:description="list.label.description" :description="list.label.description"
:scoped="showScopedLabels(list.label)" :scoped="showScopedLabels(list.label)"
:size="!list.isExpanded ? 'sm' : ''" :size="list.collapsed ? 'sm' : ''"
:title="list.label.title" :title="list.label.title"
/> />
</div> </div>
<!-- EE start -->
<span <span
v-if="isSwimlanesHeader && !list.isExpanded" v-if="isSwimlanesHeader && list.collapsed"
ref="collapsedInfo" ref="collapsedInfo"
aria-hidden="true" aria-hidden="true"
class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
> >
<gl-icon name="information" /> <gl-icon name="information" />
</span> </span>
<gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> <gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0"> <div v-if="list.maxIssueCount !== 0">
&#8226;
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template> <template #issuesSize>{{ issuesTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
<div v-else>&#8226; {{ issuesTooltipLabel }}</div> <div v-else> {{ issuesTooltipLabel }}</div>
<div v-if="weightFeatureAvailable"> <div v-if="weightFeatureAvailable">
&#8226;
<gl-sprintf :message="__('%{totalWeight} total weight')"> <gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template> <template #totalWeight>{{ list.totalWeight }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
</gl-tooltip> </gl-tooltip>
<!-- EE end -->
<div <div
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
:class="{ :class="{
'gl-display-none!': !list.isExpanded && isSwimlanesHeader, 'gl-display-none!': list.collapsed && isSwimlanesHeader,
'gl-p-0': !list.isExpanded, 'gl-p-0': list.collapsed,
}" }"
> >
<span class="gl-display-inline-flex"> <span class="gl-display-inline-flex">
...@@ -293,7 +315,7 @@ export default { ...@@ -293,7 +315,7 @@ export default {
<gl-icon class="gl-mr-2" name="issues" /> <gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span> </span>
<!-- The following is only true in EE. --> <!-- EE start -->
<template v-if="weightFeatureAvailable"> <template v-if="weightFeatureAvailable">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
...@@ -301,6 +323,7 @@ export default { ...@@ -301,6 +323,7 @@ export default {
{{ list.totalWeight }} {{ list.totalWeight }}
</span> </span>
</template> </template>
<!-- EE end -->
</span> </span>
</div> </div>
<gl-button-group <gl-button-group
...@@ -309,13 +332,11 @@ export default { ...@@ -309,13 +332,11 @@ export default {
> >
<gl-button <gl-button
v-if="isNewIssueShown" v-if="isNewIssueShown"
v-show="!list.collapsed"
ref="newIssueBtn" ref="newIssueBtn"
v-gl-tooltip.hover v-gl-tooltip.hover
:class="{ :aria-label="$options.i18n.newIssue"
'gl-display-none': !list.isExpanded, :title="$options.i18n.newIssue"
}"
:aria-label="__('New issue')"
:title="__('New issue')"
class="issue-count-badge-add-button no-drag" class="issue-count-badge-add-button no-drag"
icon="plus" icon="plus"
@click="showNewIssueForm" @click="showNewIssueForm"
...@@ -325,13 +346,13 @@ export default { ...@@ -325,13 +346,13 @@ export default {
v-if="isSettingsShown" v-if="isSettingsShown"
ref="settingsBtn" ref="settingsBtn"
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="__('List settings')" :aria-label="$options.i18n.listSettings"
class="no-drag js-board-settings-button" class="no-drag js-board-settings-button"
:title="__('List settings')" :title="$options.i18n.listSettings"
icon="settings" icon="settings"
@click="openSidebarSettings" @click="openSidebarSettings"
/> />
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
</gl-button-group> </gl-button-group>
</h3> </h3>
</header> </header>
......
...@@ -9,22 +9,18 @@ import { ...@@ -9,22 +9,18 @@ import {
GlSprintf, GlSprintf,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { n__, s__, __ } from '~/locale'; import { n__, s__ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import IssueCount from './issue_count.vue'; import IssueCount from './issue_count.vue';
import boardsStore from '../stores/boards_store';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
import { inactiveId, LIST, ListType } from '../constants'; import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils'; import { isScopedLabel } from '~/lib/utils/common_utils';
import { isListDraggable } from '~/boards/boards_util';
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
export default { export default {
i18n: {
newIssue: __('New issue'),
listSettings: __('List settings'),
expand: s__('Boards|Expand'),
collapse: s__('Boards|Collapse'),
},
components: { components: {
GlButtonGroup, GlButtonGroup,
GlButton, GlButton,
...@@ -41,15 +37,6 @@ export default { ...@@ -41,15 +37,6 @@ export default {
boardId: { boardId: {
default: '', default: '',
}, },
weightFeatureAvailable: {
default: false,
},
scopedLabelsAvailable: {
default: false,
},
currentUserId: {
default: null,
},
}, },
props: { props: {
list: { list: {
...@@ -67,53 +54,56 @@ export default { ...@@ -67,53 +54,56 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
weightFeatureAvailable: false,
};
},
computed: { computed: {
...mapState(['activeId']), ...mapState(['activeId']),
isLoggedIn() { isLoggedIn() {
return Boolean(this.currentUserId); return Boolean(gon.current_user_id);
}, },
listType() { listType() {
return this.list.listType; return this.list.type;
}, },
listAssignee() { listAssignee() {
return this.list?.assignee?.username || ''; return this.list?.assignee?.username || '';
}, },
listTitle() { listTitle() {
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || ''; return this.list?.label?.description || this.list.title || '';
}, },
showListHeaderButton() { showListHeaderButton() {
return !this.disabled && this.listType !== ListType.closed; return !this.disabled && this.listType !== ListType.closed;
}, },
showMilestoneListDetails() { showMilestoneListDetails() {
return ( return (
this.listType === ListType.milestone && this.list.type === 'milestone' &&
this.list.milestone && this.list.milestone &&
(!this.list.collapsed || !this.isSwimlanesHeader) (this.list.isExpanded || !this.isSwimlanesHeader)
); );
}, },
showAssigneeListDetails() { showAssigneeListDetails() {
return ( return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
);
}, },
issuesCount() { issuesCount() {
return this.list.issuesCount; return this.list.issuesSize;
}, },
issuesTooltipLabel() { issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount); return n__(`%d issue`, `%d issues`, this.issuesCount);
}, },
chevronTooltip() { chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
}, },
chevronIcon() { chevronIcon() {
return this.list.collapsed ? 'chevron-down' : 'chevron-right'; return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
}, },
isNewIssueShown() { isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton; return this.listType === ListType.backlog || this.showListHeaderButton;
}, },
isSettingsShown() { isSettingsShown() {
return ( return (
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
); );
}, },
uniqueKey() { uniqueKey() {
...@@ -123,15 +113,9 @@ export default { ...@@ -123,15 +113,9 @@ export default {
collapsedTooltipTitle() { collapsedTooltipTitle() {
return this.listTitle || this.listAssignee; return this.listTitle || this.listAssignee;
}, },
headerStyle() {
return { borderTopColor: this.list?.label?.color };
},
userCanDrag() {
return !this.disabled && isListDraggable(this.list);
},
}, },
methods: { methods: {
...mapActions(['updateList', 'setActiveId']), ...mapActions(['setActiveId']),
openSidebarSettings() { openSidebarSettings() {
if (this.activeId === inactiveId) { if (this.activeId === inactiveId) {
sidebarEventHub.$emit('sidebar.closeAll'); sidebarEventHub.$emit('sidebar.closeAll');
...@@ -140,14 +124,14 @@ export default { ...@@ -140,14 +124,14 @@ export default {
this.setActiveId({ id: this.list.id, sidebarType: LIST }); this.setActiveId({ id: this.list.id, sidebarType: LIST });
}, },
showScopedLabels(label) { showScopedLabels(label) {
return this.scopedLabelsAvailable && isScopedLabel(label); return boardsStore.scopedLabels.enabled && isScopedLabel(label);
}, },
showNewIssueForm() { showNewIssueForm() {
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`toggle-issue-form-${this.list.id}`);
}, },
toggleExpanded() { toggleExpanded() {
this.list.collapsed = !this.list.collapsed; this.list.isExpanded = !this.list.isExpanded;
if (!this.isLoggedIn) { if (!this.isLoggedIn) {
this.addToLocalStorage(); this.addToLocalStorage();
...@@ -161,11 +145,11 @@ export default { ...@@ -161,11 +145,11 @@ export default {
}, },
addToLocalStorage() { addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) { if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed); localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
} }
}, },
updateListFunction() { updateListFunction() {
this.updateList({ listId: this.list.id, collapsed: this.list.collapsed }); this.list.update();
}, },
}, },
}; };
...@@ -175,25 +159,26 @@ export default { ...@@ -175,25 +159,26 @@ export default {
<header <header
:class="{ :class="{
'has-border': list.label && list.label.color, 'has-border': list.label && list.label.color,
'gl-h-full': list.collapsed, 'gl-h-full': !list.isExpanded,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}" }"
:style="headerStyle" :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
class="board-header gl-relative" class="board-header gl-relative"
data-qa-selector="board_list_header" data-qa-selector="board_list_header"
data-testid="board-list-header" data-testid="board-list-header"
> >
<h3 <h3
:class="{ :class="{
'user-can-drag': userCanDrag, 'user-can-drag': !disabled && !list.preset,
'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader, 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
'gl-border-b-0': list.collapsed || isSwimlanesHeader, 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
'gl-py-2': list.collapsed && isSwimlanesHeader, 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
'gl-flex-direction-column': list.collapsed, 'gl-flex-direction-column': !list.isExpanded,
}" }"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
> >
<gl-button <gl-button
v-if="list.isExpandable"
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="chevronTooltip" :aria-label="chevronTooltip"
:title="chevronTooltip" :title="chevronTooltip"
...@@ -203,14 +188,14 @@ export default { ...@@ -203,14 +188,14 @@ export default {
size="small" size="small"
@click="toggleExpanded" @click="toggleExpanded"
/> />
<!-- EE start --> <!-- The following is only true in EE and if it is a milestone -->
<span <span
v-if="showMilestoneListDetails" v-if="showMilestoneListDetails"
aria-hidden="true" aria-hidden="true"
class="milestone-icon" class="milestone-icon"
:class="{ :class="{
'gl-mt-3 gl-rotate-90': list.collapsed, 'gl-mt-3 gl-rotate-90': !list.isExpanded,
'gl-mr-2': !list.collapsed, 'gl-mr-2': list.isExpanded,
}" }"
> >
<gl-icon name="timer" /> <gl-icon name="timer" />
...@@ -218,95 +203,90 @@ export default { ...@@ -218,95 +203,90 @@ export default {
<a <a
v-if="showAssigneeListDetails" v-if="showAssigneeListDetails"
:href="list.assignee.webUrl" :href="list.assignee.path"
class="user-avatar-link js-no-trigger" class="user-avatar-link js-no-trigger"
:class="{ :class="{
'gl-mt-3 gl-rotate-90': list.collapsed, 'gl-mt-3 gl-rotate-90': !list.isExpanded,
}" }"
> >
<img <img
v-gl-tooltip.hover.bottom v-gl-tooltip.hover.bottom
:title="listAssignee" :title="listAssignee"
:alt="list.assignee.name" :alt="list.assignee.name"
:src="list.assignee.avatarUrl" :src="list.assignee.avatar"
class="avatar s20" class="avatar s20"
height="20" height="20"
width="20" width="20"
/> />
</a> </a>
<!-- EE end -->
<div <div
class="board-title-text" class="board-title-text"
:class="{ :class="{
'gl-display-none': list.collapsed && isSwimlanesHeader, 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed, 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
'gl-flex-grow-1': !list.collapsed, 'gl-flex-grow-1': list.isExpanded,
}" }"
> >
<!-- EE start -->
<span <span
v-if="listType !== 'label'" v-if="list.type !== 'label'"
v-gl-tooltip.hover v-gl-tooltip.hover
:class="{ :class="{
'gl-display-block': list.collapsed || listType === 'milestone', 'gl-display-block': !list.isExpanded || list.type === 'milestone',
}" }"
:title="listTitle" :title="listTitle"
class="board-title-main-text gl-text-truncate" class="board-title-main-text gl-text-truncate"
> >
{{ listTitle }} {{ list.title }}
</span> </span>
<span <span
v-if="listType === 'assignee'" v-if="list.type === 'assignee'"
v-show="!list.collapsed"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
:class="{ 'gl-display-none': !list.isExpanded }"
> >
@{{ listAssignee }} @{{ listAssignee }}
</span> </span>
<!-- EE end -->
<gl-label <gl-label
v-if="listType === 'label'" v-if="list.type === 'label'"
v-gl-tooltip.hover.bottom v-gl-tooltip.hover.bottom
:background-color="list.label.color" :background-color="list.label.color"
:description="list.label.description" :description="list.label.description"
:scoped="showScopedLabels(list.label)" :scoped="showScopedLabels(list.label)"
:size="list.collapsed ? 'sm' : ''" :size="!list.isExpanded ? 'sm' : ''"
:title="list.label.title" :title="list.label.title"
/> />
</div> </div>
<!-- EE start -->
<span <span
v-if="isSwimlanesHeader && list.collapsed" v-if="isSwimlanesHeader && !list.isExpanded"
ref="collapsedInfo" ref="collapsedInfo"
aria-hidden="true" aria-hidden="true"
class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500" class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
> >
<gl-icon name="information" /> <gl-icon name="information" />
</span> </span>
<gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo"> <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0"> <div v-if="list.maxIssueCount !== 0">
&#8226;
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
<template #issuesSize>{{ issuesTooltipLabel }}</template> <template #issuesSize>{{ issuesTooltipLabel }}</template>
<template #maxIssueCount>{{ list.maxIssueCount }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
<div v-else> {{ issuesTooltipLabel }}</div> <div v-else>&#8226; {{ issuesTooltipLabel }}</div>
<div v-if="weightFeatureAvailable"> <div v-if="weightFeatureAvailable">
&#8226;
<gl-sprintf :message="__('%{totalWeight} total weight')"> <gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template> <template #totalWeight>{{ list.totalWeight }}</template>
</gl-sprintf> </gl-sprintf>
</div> </div>
</gl-tooltip> </gl-tooltip>
<!-- EE end -->
<div <div
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
:class="{ :class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader, 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
'gl-p-0': list.collapsed, 'gl-p-0': !list.isExpanded,
}" }"
> >
<span class="gl-display-inline-flex"> <span class="gl-display-inline-flex">
...@@ -315,7 +295,7 @@ export default { ...@@ -315,7 +295,7 @@ export default {
<gl-icon class="gl-mr-2" name="issues" /> <gl-icon class="gl-mr-2" name="issues" />
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
</span> </span>
<!-- EE start --> <!-- The following is only true in EE. -->
<template v-if="weightFeatureAvailable"> <template v-if="weightFeatureAvailable">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
...@@ -323,7 +303,6 @@ export default { ...@@ -323,7 +303,6 @@ export default {
{{ list.totalWeight }} {{ list.totalWeight }}
</span> </span>
</template> </template>
<!-- EE end -->
</span> </span>
</div> </div>
<gl-button-group <gl-button-group
...@@ -332,11 +311,13 @@ export default { ...@@ -332,11 +311,13 @@ export default {
> >
<gl-button <gl-button
v-if="isNewIssueShown" v-if="isNewIssueShown"
v-show="!list.collapsed"
ref="newIssueBtn" ref="newIssueBtn"
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="$options.i18n.newIssue" :class="{
:title="$options.i18n.newIssue" 'gl-display-none': !list.isExpanded,
}"
:aria-label="__('New issue')"
:title="__('New issue')"
class="issue-count-badge-add-button no-drag" class="issue-count-badge-add-button no-drag"
icon="plus" icon="plus"
@click="showNewIssueForm" @click="showNewIssueForm"
...@@ -346,13 +327,13 @@ export default { ...@@ -346,13 +327,13 @@ export default {
v-if="isSettingsShown" v-if="isSettingsShown"
ref="settingsBtn" ref="settingsBtn"
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="$options.i18n.listSettings" :aria-label="__('List settings')"
class="no-drag js-board-settings-button" class="no-drag js-board-settings-button"
:title="$options.i18n.listSettings" :title="__('List settings')"
icon="settings" icon="settings"
@click="openSidebarSettings" @click="openSidebarSettings"
/> />
<gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip> <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
</gl-button-group> </gl-button-group>
</h3> </h3>
</header> </header>
......
<script>
import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import defaultSortableConfig from '~/sortable/sortable_config';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import BoardNewIssue from './board_new_issue_new.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
import { sprintf, __ } from '~/locale';
export default {
name: 'BoardList',
i18n: {
loadingIssues: __('Loading issues'),
loadingMoreissues: __('Loading more issues'),
showingAllIssues: __('Showing all issues'),
},
components: {
BoardCard,
BoardNewIssue,
GlLoadingIcon,
},
props: {
disabled: {
type: Boolean,
required: true,
},
list: {
type: Object,
required: true,
},
issues: {
type: Array,
required: true,
},
canAdminList: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
scrollOffset: 250,
showCount: false,
showIssueForm: false,
};
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.issues.length,
total: this.list.issuesCount,
});
},
issuesSizeExceedsMax() {
return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
},
loading() {
return this.listsFlags[this.list.id]?.isLoading;
},
loadingMore() {
return this.listsFlags[this.list.id]?.isLoadingMore;
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
showingAllIssues() {
return this.issues.length === this.list.issuesCount;
},
treeRootWrapper() {
return this.canAdminList ? Draggable : 'ul';
},
treeRootOptions() {
const options = {
...defaultSortableConfig,
fallbackOnBody: false,
group: 'board-list',
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
value: this.issues,
};
return this.canAdminList ? options : {};
},
},
watch: {
issues() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
},
},
created() {
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
// Scroll event on list to load more
this.listRef.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
...mapActions(['fetchIssuesForList', 'moveIssue']),
listHeight() {
return this.listRef.getBoundingClientRect().height;
},
scrollHeight() {
return this.listRef.scrollHeight;
},
scrollTop() {
return this.listRef.scrollTop + this.listHeight();
},
scrollToTop() {
this.listRef.scrollTop = 0;
},
loadNextPage() {
this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
},
onScroll() {
window.requestAnimationFrame(() => {
if (
!this.loadingMore &&
this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
this.hasNextPage
) {
this.loadNextPage();
}
});
},
handleDragOnStart() {
sortableStart();
},
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
const getIssueId = (el) => Number(el.dataset.issueId);
// If issue is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before
moveBeforeId = getIssueId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after
moveAfterId = getIssueId(children[newIndex]);
} else {
// If issue remains in the same list at the same position we do nothing
return;
}
} else {
// We look for the issue that ends up before the moved issue if it exists
if (children[newIndex - 1]) {
moveBeforeId = getIssueId(children[newIndex - 1]);
}
// We look for the issue that ends up after the moved issue if it exists
if (children[newIndex]) {
moveAfterId = getIssueId(children[newIndex]);
}
}
this.moveIssue({
issueId,
issueIid,
issuePath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
moveAfterId,
});
},
},
};
</script>
<template>
<div
v-show="!list.collapsed"
class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
data-qa-selector="board_list_cards_area"
>
<div
v-if="loading"
class="gl-mt-4 gl-text-center"
:aria-label="$options.i18n.loadingIssues"
data-testid="board_list_loading"
>
<gl-loading-icon />
</div>
<board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
<component
:is="treeRootWrapper"
v-show="!loading"
ref="list"
v-bind="treeRootOptions"
:data-board="list.id"
:data-board-type="list.listType"
:class="{ 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
>
<board-card
v-for="(issue, index) in issues"
ref="issue"
:key="issue.id"
:index="index"
:list="list"
:issue="issue"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
<gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
<span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
</component>
</div>
</template>
<script> <script>
import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util'; import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ProjectSelect from './project_select_deprecated.vue'; import ProjectSelect from './project_select.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
i18n: {
submit: __('Submit issue'),
cancel: __('Cancel'),
},
components: { components: {
ProjectSelect, ProjectSelect,
GlButton, GlButton,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
inject: ['groupId'], inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
props: { props: {
list: { list: {
type: Object, type: Object,
...@@ -26,69 +28,58 @@ export default { ...@@ -26,69 +28,58 @@ export default {
data() { data() {
return { return {
title: '', title: '',
error: false,
selectedProject: {},
}; };
}, },
computed: { computed: {
...mapState(['selectedProject']),
disabled() { disabled() {
if (this.groupId) { if (this.groupId) {
return this.title === '' || !this.selectedProject.name; return this.title === '' || !this.selectedProject.name;
} }
return this.title === ''; return this.title === '';
}, },
inputFieldId() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.list.id}-title`;
},
}, },
mounted() { mounted() {
this.$refs.input.focus(); this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject); eventHub.$on('setSelectedProject', this.setSelectedProject);
}, },
methods: { methods: {
...mapActions(['addListNewIssue']),
submit(e) { submit(e) {
e.preventDefault(); e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
this.error = false;
const labels = this.list.label ? [this.list.label] : []; const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : []; const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list); const milestone = getMilestone(this.list);
const { weightFeatureAvailable } = boardsStore; const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
const issue = new ListIssue({ const { title } = this;
title: this.title,
labels,
subscribed: true,
assignees,
milestone,
project_id: this.selectedProject.id,
weight,
});
eventHub.$emit(`scroll-board-list-${this.list.id}`); eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
return this.list return this.addListNewIssue({
.newIssue(issue) issueInput: {
.then(() => { title,
boardsStore.setIssueDetail(issue); labelIds: labels?.map((l) => l.id),
boardsStore.setListDetail(this.list); assigneeIds: assignees?.map((a) => a?.id),
}) milestoneId: milestone?.id,
.catch(() => { projectPath: this.selectedProject.fullPath,
this.list.removeIssue(issue); weight: weight >= 0 ? weight : null,
},
// Show error message list: this.list,
this.error = true; }).then(() => {
}); this.reset();
});
}, },
cancel() { reset() {
this.title = ''; this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`toggle-issue-form-${this.list.id}`);
}, },
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
}, },
}; };
</script> </script>
...@@ -96,13 +87,10 @@ export default { ...@@ -96,13 +87,10 @@ export default {
<template> <template>
<div class="board-new-issue-form"> <div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded"> <div class="board-card position-relative p-3 rounded">
<form @submit="submit($event)"> <form ref="submitForm" @submit="submit">
<div v-if="error" class="flash-container"> <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
<div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
</div>
<label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input <input
:id="list.id + '-title'" :id="inputFieldId"
ref="input" ref="input"
v-model="title" v-model="title"
class="form-control" class="form-control"
...@@ -119,16 +107,18 @@ export default { ...@@ -119,16 +107,18 @@ export default {
variant="success" variant="success"
category="primary" category="primary"
type="submit" type="submit"
>{{ __('Submit issue') }}</gl-button
> >
{{ $options.i18n.submit }}
</gl-button>
<gl-button <gl-button
ref="cancelButton" ref="cancelButton"
class="float-right" class="float-right"
type="button" type="button"
variant="default" variant="default"
@click="cancel" @click="reset"
>{{ __('Cancel') }}</gl-button
> >
{{ $options.i18n.cancel }}
</gl-button>
</div> </div>
</form> </form>
</div> </div>
......
<script> <script>
import { mapActions, mapState } from 'vuex';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { getMilestone } from 'ee_else_ce/boards/boards_util'; import { getMilestone } from 'ee_else_ce/boards/boards_util';
import ListIssue from 'ee_else_ce/boards/models/issue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue'; import ProjectSelect from './project_select_deprecated.vue';
import boardsStore from '../stores/boards_store';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
export default { export default {
name: 'BoardNewIssue', name: 'BoardNewIssue',
i18n: {
submit: __('Submit issue'),
cancel: __('Cancel'),
},
components: { components: {
ProjectSelect, ProjectSelect,
GlButton, GlButton,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], inject: ['groupId'],
props: { props: {
list: { list: {
type: Object, type: Object,
...@@ -28,57 +26,69 @@ export default { ...@@ -28,57 +26,69 @@ export default {
data() { data() {
return { return {
title: '', title: '',
error: false,
selectedProject: {},
}; };
}, },
computed: { computed: {
...mapState(['selectedProject']),
disabled() { disabled() {
if (this.groupId) { if (this.groupId) {
return this.title === '' || !this.selectedProject.name; return this.title === '' || !this.selectedProject.name;
} }
return this.title === ''; return this.title === '';
}, },
inputFieldId() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.list.id}-title`;
},
}, },
mounted() { mounted() {
this.$refs.input.focus(); this.$refs.input.focus();
eventHub.$on('setSelectedProject', this.setSelectedProject);
}, },
methods: { methods: {
...mapActions(['addListNewIssue']),
submit(e) { submit(e) {
e.preventDefault(); e.preventDefault();
if (this.title.trim() === '') return Promise.resolve();
this.error = false;
const labels = this.list.label ? [this.list.label] : []; const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : []; const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list); const milestone = getMilestone(this.list);
const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; const { weightFeatureAvailable } = boardsStore;
const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
const { title } = this; const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees,
milestone,
project_id: this.selectedProject.id,
weight,
});
eventHub.$emit(`scroll-board-list-${this.list.id}`); eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
return this.addListNewIssue({ return this.list
issueInput: { .newIssue(issue)
title, .then(() => {
labelIds: labels?.map((l) => l.id), boardsStore.setIssueDetail(issue);
assigneeIds: assignees?.map((a) => a?.id), boardsStore.setListDetail(this.list);
milestoneId: milestone?.id, })
projectPath: this.selectedProject.fullPath, .catch(() => {
weight: weight >= 0 ? weight : null, this.list.removeIssue(issue);
},
list: this.list, // Show error message
}).then(() => { this.error = true;
this.reset(); });
});
}, },
reset() { cancel() {
this.title = ''; this.title = '';
eventHub.$emit(`toggle-issue-form-${this.list.id}`); eventHub.$emit(`toggle-issue-form-${this.list.id}`);
}, },
setSelectedProject(selectedProject) {
this.selectedProject = selectedProject;
},
}, },
}; };
</script> </script>
...@@ -86,10 +96,13 @@ export default { ...@@ -86,10 +96,13 @@ export default {
<template> <template>
<div class="board-new-issue-form"> <div class="board-new-issue-form">
<div class="board-card position-relative p-3 rounded"> <div class="board-card position-relative p-3 rounded">
<form ref="submitForm" @submit="submit"> <form @submit="submit($event)">
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> <div v-if="error" class="flash-container">
<div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
</div>
<label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input <input
:id="inputFieldId" :id="list.id + '-title'"
ref="input" ref="input"
v-model="title" v-model="title"
class="form-control" class="form-control"
...@@ -106,18 +119,16 @@ export default { ...@@ -106,18 +119,16 @@ export default {
variant="success" variant="success"
category="primary" category="primary"
type="submit" type="submit"
>{{ __('Submit issue') }}</gl-button
> >
{{ $options.i18n.submit }}
</gl-button>
<gl-button <gl-button
ref="cancelButton" ref="cancelButton"
class="float-right" class="float-right"
type="button" type="button"
variant="default" variant="default"
@click="reset" @click="cancel"
>{{ __('Cancel') }}</gl-button
> >
{{ $options.i18n.cancel }}
</gl-button>
</div> </div>
</form> </form>
</div> </div>
......
...@@ -68,8 +68,10 @@ export default () => { ...@@ -68,8 +68,10 @@ export default () => {
issueBoardsApp.$destroy(true); issueBoardsApp.$destroy(true);
} }
boardsStore.create(); if (!gon?.features?.graphqlBoardLists) {
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); boardsStore.create();
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
}
issueBoardsApp = new Vue({ issueBoardsApp = new Vue({
el: $boardApp, el: $boardApp,
......
<script> <script>
import BoardListHeaderFoss from '~/boards/components/board_list_header.vue'; import BoardListHeaderFoss from '~/boards/components/board_list_header.vue';
import { __, sprintf, s__ } from '~/locale'; import { __, sprintf, s__ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
export default { export default {
extends: BoardListHeaderFoss, extends: BoardListHeaderFoss,
data() { inject: ['weightFeatureAvailable'],
return {
weightFeatureAvailable: boardsStore.weightFeatureAvailable,
};
},
computed: { computed: {
issuesTooltip() { issuesTooltip() {
const { maxIssueCount } = this.list; const { maxIssueCount } = this.list;
......
<script> <script>
import BoardListHeaderFoss from '~/boards/components/board_list_header_new.vue'; import BoardListHeaderFoss from '~/boards/components/board_list_header_deprecated.vue';
import { __, sprintf, s__ } from '~/locale'; import { __, sprintf, s__ } from '~/locale';
import boardsStore from '~/boards/stores/boards_store';
export default { export default {
extends: BoardListHeaderFoss, extends: BoardListHeaderFoss,
inject: ['weightFeatureAvailable'], data() {
return {
weightFeatureAvailable: boardsStore.weightFeatureAvailable,
};
},
computed: { computed: {
issuesTooltip() { issuesTooltip() {
const { maxIssueCount } = this.list; const { maxIssueCount } = this.list;
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
import { DRAGGABLE_TAG } from '../constants'; import { DRAGGABLE_TAG } from '../constants';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
......
...@@ -5,7 +5,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -5,7 +5,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import defaultSortableConfig from '~/sortable/sortable_config'; import defaultSortableConfig from '~/sortable/sortable_config';
import BoardCardLayout from '~/boards/components/board_card_layout.vue'; import BoardCardLayout from '~/boards/components/board_card_layout.vue';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import { ISSUABLE } from '~/boards/constants'; import { ISSUABLE } from '~/boards/constants';
export default { export default {
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardListHeader from 'ee/boards/components/board_list_header_new.vue'; import BoardListHeader from 'ee/boards/components/board_list_header_deprecated.vue';
import getters from 'ee/boards/stores/getters'; import getters from 'ee/boards/stores/getters';
import { mockLabelList } from 'jest/boards/mock_data'; import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
import { ListType, inactiveId } from '~/boards/constants'; import { ListType, inactiveId } from '~/boards/constants';
import List from '~/boards/models/list';
import axios from '~/lib/utils/axios_utils';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -14,15 +19,20 @@ localVue.use(Vuex); ...@@ -14,15 +19,20 @@ localVue.use(Vuex);
describe('Board List Header Component', () => { describe('Board List Header Component', () => {
let store; let store;
let wrapper; let wrapper;
let axiosMock;
beforeEach(() => { beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
store = new Vuex.Store({ state: { activeId: inactiveId }, getters }); store = new Vuex.Store({ state: { activeId: inactiveId }, getters });
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear(); localStorage.clear();
}); });
...@@ -31,25 +41,26 @@ describe('Board List Header Component', () => { ...@@ -31,25 +41,26 @@ describe('Board List Header Component', () => {
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
withLocalStorage = true, withLocalStorage = true,
isSwimlanesHeader = false,
weightFeatureAvailable = false,
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...mockLabelList, ...listObj,
listType, list_type: listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.assignee = {}; listMock.user = {};
} }
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) { if (withLocalStorage) {
localStorage.setItem( localStorage.setItem(
`boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, `boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(), (!collapsed).toString(),
); );
} }
...@@ -59,12 +70,10 @@ describe('Board List Header Component', () => { ...@@ -59,12 +70,10 @@ describe('Board List Header Component', () => {
localVue, localVue,
propsData: { propsData: {
disabled: false, disabled: false,
list: listMock, list,
isSwimlanesHeader,
}, },
provide: { provide: {
boardId, boardId,
weightFeatureAvailable,
}, },
}); });
}; };
...@@ -88,8 +97,6 @@ describe('Board List Header Component', () => { ...@@ -88,8 +97,6 @@ describe('Board List Header Component', () => {
}); });
it('has a test for each list type', () => { it('has a test for each list type', () => {
createComponent();
Object.values(ListType).forEach((value) => { Object.values(ListType).forEach((value) => {
expect([...hasSettings, ...hasNoSettings]).toContain(value); expect([...hasSettings, ...hasNoSettings]).toContain(value);
}); });
...@@ -109,7 +116,7 @@ describe('Board List Header Component', () => { ...@@ -109,7 +116,7 @@ describe('Board List Header Component', () => {
}); });
it('does not emit event when there is an active List', () => { it('does not emit event when there is an active List', () => {
store.state.activeId = mockLabelList.id; store.state.activeId = listObj.id;
createComponent({ listType: hasSettings[0] }); createComponent({ listType: hasSettings[0] });
wrapper.vm.openSidebarSettings(); wrapper.vm.openSidebarSettings();
...@@ -117,26 +124,4 @@ describe('Board List Header Component', () => { ...@@ -117,26 +124,4 @@ describe('Board List Header Component', () => {
}); });
}); });
}); });
describe('Swimlanes header', () => {
it('when collapsed, it displays info icon', () => {
createComponent({ isSwimlanesHeader: true, collapsed: true });
expect(wrapper.find('.board-header-collapsed-info-icon').exists()).toBe(true);
});
});
describe('weightFeatureAvailable', () => {
it('weightFeatureAvailable is true', () => {
createComponent({ weightFeatureAvailable: true });
expect(wrapper.find({ ref: 'weightTooltip' }).exists()).toBe(true);
});
it('weightFeatureAvailable is false', () => {
createComponent();
expect(wrapper.find({ ref: 'weightTooltip' }).exists()).toBe(false);
});
});
}); });
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import BoardListHeader from 'ee/boards/components/board_list_header.vue'; import BoardListHeader from 'ee/boards/components/board_list_header.vue';
import getters from 'ee/boards/stores/getters'; import getters from 'ee/boards/stores/getters';
import { TEST_HOST } from 'helpers/test_constants'; import { mockLabelList } from 'jest/boards/mock_data';
import { listObj } from 'jest/boards/mock_data';
import { ListType, inactiveId } from '~/boards/constants'; import { ListType, inactiveId } from '~/boards/constants';
import List from '~/boards/models/list';
import axios from '~/lib/utils/axios_utils';
import sidebarEventHub from '~/sidebar/event_hub'; import sidebarEventHub from '~/sidebar/event_hub';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -19,20 +14,15 @@ localVue.use(Vuex); ...@@ -19,20 +14,15 @@ localVue.use(Vuex);
describe('Board List Header Component', () => { describe('Board List Header Component', () => {
let store; let store;
let wrapper; let wrapper;
let axiosMock;
beforeEach(() => { beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
store = new Vuex.Store({ state: { activeId: inactiveId }, getters }); store = new Vuex.Store({ state: { activeId: inactiveId }, getters });
jest.spyOn(store, 'dispatch').mockImplementation(); jest.spyOn(store, 'dispatch').mockImplementation();
}); });
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear(); localStorage.clear();
}); });
...@@ -41,26 +31,25 @@ describe('Board List Header Component', () => { ...@@ -41,26 +31,25 @@ describe('Board List Header Component', () => {
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
withLocalStorage = true, withLocalStorage = true,
isSwimlanesHeader = false,
weightFeatureAvailable = false,
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...listObj, ...mockLabelList,
list_type: listType, listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.user = {}; listMock.assignee = {};
} }
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) { if (withLocalStorage) {
localStorage.setItem( localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`, `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
(!collapsed).toString(), (!collapsed).toString(),
); );
} }
...@@ -70,10 +59,12 @@ describe('Board List Header Component', () => { ...@@ -70,10 +59,12 @@ describe('Board List Header Component', () => {
localVue, localVue,
propsData: { propsData: {
disabled: false, disabled: false,
list, list: listMock,
isSwimlanesHeader,
}, },
provide: { provide: {
boardId, boardId,
weightFeatureAvailable,
}, },
}); });
}; };
...@@ -97,6 +88,8 @@ describe('Board List Header Component', () => { ...@@ -97,6 +88,8 @@ describe('Board List Header Component', () => {
}); });
it('has a test for each list type', () => { it('has a test for each list type', () => {
createComponent();
Object.values(ListType).forEach((value) => { Object.values(ListType).forEach((value) => {
expect([...hasSettings, ...hasNoSettings]).toContain(value); expect([...hasSettings, ...hasNoSettings]).toContain(value);
}); });
...@@ -116,7 +109,7 @@ describe('Board List Header Component', () => { ...@@ -116,7 +109,7 @@ describe('Board List Header Component', () => {
}); });
it('does not emit event when there is an active List', () => { it('does not emit event when there is an active List', () => {
store.state.activeId = listObj.id; store.state.activeId = mockLabelList.id;
createComponent({ listType: hasSettings[0] }); createComponent({ listType: hasSettings[0] });
wrapper.vm.openSidebarSettings(); wrapper.vm.openSidebarSettings();
...@@ -124,4 +117,26 @@ describe('Board List Header Component', () => { ...@@ -124,4 +117,26 @@ describe('Board List Header Component', () => {
}); });
}); });
}); });
describe('Swimlanes header', () => {
it('when collapsed, it displays info icon', () => {
createComponent({ isSwimlanesHeader: true, collapsed: true });
expect(wrapper.find('.board-header-collapsed-info-icon').exists()).toBe(true);
});
});
describe('weightFeatureAvailable', () => {
it('weightFeatureAvailable is true', () => {
createComponent({ weightFeatureAvailable: true });
expect(wrapper.find({ ref: 'weightTooltip' }).exists()).toBe(true);
});
it('weightFeatureAvailable is false', () => {
createComponent();
expect(wrapper.find({ ref: 'weightTooltip' }).exists()).toBe(false);
});
});
}); });
...@@ -6,7 +6,7 @@ import EpicLane from 'ee/boards/components/epic_lane.vue'; ...@@ -6,7 +6,7 @@ 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 getters from 'ee/boards/stores/getters'; import getters from 'ee/boards/stores/getters';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.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(); const localVue = createLocalVue();
......
/* global List */
/* global ListIssue */
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import eventHub from '~/boards/eventhub';
import BoardList from '~/boards/components/board_list_deprecated.vue';
import '~/boards/models/issue';
import '~/boards/models/list';
import { listObj, boardsMockInterceptor } from './mock_data';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
const el = document.createElement('div');
document.body.appendChild(el);
const mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor);
boardsStore.create();
const BoardListComp = Vue.extend(BoardList);
const list = new List({ ...listObj, ...listProps });
const issue = new ListIssue({
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
...listIssueProps,
});
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
list.issuesSize = 1;
}
list.issues.push(issue);
const component = new BoardListComp({
el,
store,
propsData: {
disabled: false,
list,
issues: list.issues,
...componentProps,
},
provide: {
groupId: null,
rootPath: '/',
},
}).$mount();
Vue.nextTick(() => {
done();
});
return { component, mock };
};
describe('Board list component', () => {
let mock;
let component;
let getIssues;
function generateIssues(compWrapper) {
for (let i = 1; i < 20; i += 1) {
const issue = { ...compWrapper.list.issues[0] };
issue.id += i;
compWrapper.list.issues.push(issue);
}
}
describe('When Expanded', () => {
beforeEach((done) => {
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
({ mock, component } = createComponent({ done }));
});
afterEach(() => {
mock.restore();
component.$destroy();
});
it('loads first page of issues', () => {
return waitForPromises().then(() => {
expect(getIssues).toHaveBeenCalled();
});
});
it('renders component', () => {
expect(component.$el.classList.contains('board-list-component')).toBe(true);
});
it('renders loading icon', () => {
component.list.loading = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
});
});
it('renders issues', () => {
expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
});
it('sets data attribute with issue id', () => {
expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
});
it('shows new issue form', () => {
component.toggleForm();
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
});
});
it('shows new issue form after eventhub event', () => {
eventHub.$emit(`toggle-issue-form-${component.list.id}`);
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
});
});
it('does not show new issue form for closed list', () => {
component.list.type = 'closed';
component.toggleForm();
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
});
});
it('shows count list item', () => {
component.showCount = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
'Showing all issues',
);
});
});
it('sets data attribute with invalid id', () => {
component.showCount = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
'-1',
);
});
});
it('shows how many more issues to load', () => {
component.showCount = true;
component.list.issuesSize = 20;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
'Showing 1 of 20 issues',
);
});
});
it('loads more issues after scrolling', () => {
jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
generateIssues(component);
component.$refs.list.dispatchEvent(new Event('scroll'));
return waitForPromises().then(() => {
expect(component.list.nextPage).toHaveBeenCalled();
});
});
it('does not load issues if already loading', () => {
component.list.nextPage = jest
.spyOn(component.list, 'nextPage')
.mockReturnValue(new Promise(() => {}));
component.onScroll();
component.onScroll();
return waitForPromises().then(() => {
expect(component.list.nextPage).toHaveBeenCalledTimes(1);
});
});
it('shows loading more spinner', () => {
component.showCount = true;
component.list.loadingMore = true;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
});
});
});
describe('When Collapsed', () => {
beforeEach((done) => {
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
generateIssues(component);
component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
});
afterEach(() => {
mock.restore();
component.$destroy();
});
it('does not load all issues', () => {
return waitForPromises().then(() => {
// Initial getIssues from list constructor
expect(getIssues).toHaveBeenCalledTimes(1);
});
});
});
describe('max issue count warning', () => {
beforeEach((done) => {
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
});
afterEach(() => {
mock.restore();
component.$destroy();
});
describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', () => {
component.list.issuesSize = 4;
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
});
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => {
component.list.issuesSize = 2;
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
});
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => {
component.list.maxIssueCount = 0;
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
});
});
});
});
});
...@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -5,7 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue'; import Vue from 'vue';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import BoardList from '~/boards/components/board_list.vue'; import BoardList from '~/boards/components/board_list_deprecated.vue';
import '~/boards/models/issue'; import '~/boards/models/issue';
import '~/boards/models/list'; import '~/boards/models/list';
......
import Vuex from 'vuex';
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import { createLocalVue, mount } from '@vue/test-utils';
import eventHub from '~/boards/eventhub';
import BoardList from '~/boards/components/board_list_new.vue';
import BoardCard from '~/boards/components/board_card.vue';
import '~/boards/models/list';
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
import defaultState from '~/boards/stores/state';
const localVue = createLocalVue();
localVue.use(Vuex);
const actions = {
fetchIssuesForList: jest.fn(),
};
const createStore = (state = defaultState) => {
return new Vuex.Store({
state,
actions,
});
};
const createComponent = ({
listIssueProps = {},
componentProps = {},
listProps = {},
state = {},
} = {}) => {
const store = createStore({
issuesByListId: mockIssuesByListId,
issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
...state,
});
const list = {
...mockList,
...listProps,
};
const issue = {
title: 'Testing',
id: 1,
iid: 1,
confidential: false,
labels: [],
assignees: [],
...listIssueProps,
};
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesCount = 1;
}
const component = mount(BoardList, {
localVue,
propsData: {
disabled: false,
list,
issues: [issue],
canAdminList: true,
...componentProps,
},
store,
provide: {
groupId: null,
rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
},
});
return component;
};
describe('Board list component', () => {
let wrapper;
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
useFakeRequestAnimationFrame();
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('When Expanded', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders component', () => {
expect(wrapper.find('.board-list-component').exists()).toBe(true);
});
it('renders loading icon', () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
});
expect(findByTestId('board_list_loading').exists()).toBe(true);
});
it('renders issues', () => {
expect(wrapper.findAll(BoardCard).length).toBe(1);
});
it('sets data attribute with issue id', () => {
expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
});
it('shows new issue form', async () => {
wrapper.vm.toggleForm();
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
it('shows new issue form after eventhub event', async () => {
eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
});
it('does not show new issue form for closed list', () => {
wrapper.setProps({ list: { type: 'closed' } });
wrapper.vm.toggleForm();
expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
});
it('shows count list item', async () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').exists()).toBe(true);
expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
});
it('sets data attribute with invalid id', async () => {
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
});
it('shows how many more issues to load', async () => {
wrapper.vm.showCount = true;
wrapper.setProps({ list: { issuesCount: 20 } });
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
});
describe('load more issues', () => {
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesCount: 25 },
});
});
it('loads more issues after scrolling', () => {
wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
expect(actions.fetchIssuesForList).toHaveBeenCalled();
});
it('does not load issues if already loading', () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
expect(actions.fetchIssuesForList).not.toHaveBeenCalled();
});
it('shows loading more spinner', async () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
});
});
describe('max issue count warning', () => {
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesCount: 50 },
});
});
describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', async () => {
wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
await wrapper.vm.$nextTick();
expect(wrapper.find('.bg-danger-100').exists()).toBe(true);
});
});
describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => {
wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => {
wrapper.setProps({ list: { maxIssueCount: 0 } });
expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
});
describe('drag & drop issue', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('handleDragOnStart', () => {
it('adds a class `is-dragging` to document body', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false);
findByTestId('tree-root-wrapper').vm.$emit('start');
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
});
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', {
oldIndex: 1,
newIndex: 0,
item: {
dataset: {
issueId: mockIssues[0].id,
issueIid: mockIssues[0].iid,
issuePath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
from: { dataset: { listId: 'gid://gitlab/List/2' } },
});
expect(document.body.classList.contains('is-dragging')).toBe(false);
});
});
});
});
/* global List */ import Vuex from 'vuex';
/* global ListIssue */ import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
import { createLocalVue, mount } from '@vue/test-utils';
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import eventHub from '~/boards/eventhub'; import eventHub from '~/boards/eventhub';
import BoardList from '~/boards/components/board_list.vue'; import BoardList from '~/boards/components/board_list.vue';
import '~/boards/models/issue'; import BoardCard from '~/boards/components/board_card.vue';
import '~/boards/models/list'; import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
import { listObj, boardsMockInterceptor } from './mock_data'; import defaultState from '~/boards/stores/state';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store'; const localVue = createLocalVue();
localVue.use(Vuex);
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
const el = document.createElement('div'); const actions = {
fetchIssuesForList: jest.fn(),
document.body.appendChild(el); };
const mock = new MockAdapter(axios);
mock.onAny().reply(boardsMockInterceptor); const createStore = (state = defaultState) => {
boardsStore.create(); return new Vuex.Store({
state,
const BoardListComp = Vue.extend(BoardList); actions,
const list = new List({ ...listObj, ...listProps }); });
const issue = new ListIssue({ };
const createComponent = ({
listIssueProps = {},
componentProps = {},
listProps = {},
state = {},
} = {}) => {
const store = createStore({
issuesByListId: mockIssuesByListId,
issues,
pageInfoByListId: {
'gid://gitlab/List/1': { hasNextPage: true },
'gid://gitlab/List/2': {},
},
listsFlags: {
'gid://gitlab/List/1': {},
'gid://gitlab/List/2': {},
},
...state,
});
const list = {
...mockList,
...listProps,
};
const issue = {
title: 'Testing', title: 'Testing',
id: 1, id: 1,
iid: 1, iid: 1,
...@@ -31,244 +53,214 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP ...@@ -31,244 +53,214 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP
labels: [], labels: [],
assignees: [], assignees: [],
...listIssueProps, ...listIssueProps,
}); };
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) { if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
list.issuesSize = 1; list.issuesCount = 1;
} }
list.issues.push(issue);
const component = new BoardListComp({ const component = mount(BoardList, {
el, localVue,
store,
propsData: { propsData: {
disabled: false, disabled: false,
list, list,
issues: list.issues, issues: [issue],
canAdminList: true,
...componentProps, ...componentProps,
}, },
store,
provide: { provide: {
groupId: null, groupId: null,
rootPath: '/', rootPath: '/',
weightFeatureAvailable: false,
boardWeight: null,
}, },
}).$mount();
Vue.nextTick(() => {
done();
}); });
return { component, mock }; return component;
}; };
describe('Board list component', () => { describe('Board list component', () => {
let mock; let wrapper;
let component; const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
let getIssues; useFakeRequestAnimationFrame();
function generateIssues(compWrapper) {
for (let i = 1; i < 20; i += 1) {
const issue = { ...compWrapper.list.issues[0] };
issue.id += i;
compWrapper.list.issues.push(issue);
}
}
describe('When Expanded', () => { afterEach(() => {
beforeEach((done) => { wrapper.destroy();
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); wrapper = null;
({ mock, component } = createComponent({ done })); });
});
afterEach(() => {
mock.restore();
component.$destroy();
});
it('loads first page of issues', () => { describe('When Expanded', () => {
return waitForPromises().then(() => { beforeEach(() => {
expect(getIssues).toHaveBeenCalled(); wrapper = createComponent();
});
}); });
it('renders component', () => { it('renders component', () => {
expect(component.$el.classList.contains('board-list-component')).toBe(true); expect(wrapper.find('.board-list-component').exists()).toBe(true);
}); });
it('renders loading icon', () => { it('renders loading icon', () => {
component.list.loading = true; wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
}); });
expect(findByTestId('board_list_loading').exists()).toBe(true);
}); });
it('renders issues', () => { it('renders issues', () => {
expect(component.$el.querySelectorAll('.board-card').length).toBe(1); expect(wrapper.findAll(BoardCard).length).toBe(1);
}); });
it('sets data attribute with issue id', () => { it('sets data attribute with issue id', () => {
expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1'); expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
}); });
it('shows new issue form', () => { it('shows new issue form', async () => {
component.toggleForm(); wrapper.vm.toggleForm();
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); await wrapper.vm.$nextTick();
}); expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
}); });
it('shows new issue form after eventhub event', () => { it('shows new issue form after eventhub event', async () => {
eventHub.$emit(`toggle-issue-form-${component.list.id}`); eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`);
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
expect(component.$el.querySelector('.is-smaller')).not.toBeNull(); await wrapper.vm.$nextTick();
}); expect(wrapper.find('.board-new-issue-form').exists()).toBe(true);
}); });
it('does not show new issue form for closed list', () => { it('does not show new issue form for closed list', () => {
component.list.type = 'closed'; wrapper.setProps({ list: { type: 'closed' } });
component.toggleForm(); wrapper.vm.toggleForm();
return Vue.nextTick().then(() => { expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
});
}); });
it('shows count list item', () => { it('shows count list item', async () => {
component.showCount = true; wrapper.vm.showCount = true;
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(component.$el.querySelector('.board-list-count')).not.toBeNull(); expect(wrapper.find('.board-list-count').exists()).toBe(true);
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
'Showing all issues',
);
});
}); });
it('sets data attribute with invalid id', () => { it('sets data attribute with invalid id', async () => {
component.showCount = true; wrapper.vm.showCount = true;
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe( expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
'-1',
);
});
}); });
it('shows how many more issues to load', () => { it('shows how many more issues to load', async () => {
component.showCount = true; wrapper.vm.showCount = true;
component.list.issuesSize = 20; wrapper.setProps({ list: { issuesCount: 20 } });
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe( expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
'Showing 1 of 20 issues',
);
});
}); });
});
it('loads more issues after scrolling', () => { describe('load more issues', () => {
jest.spyOn(component.list, 'nextPage').mockImplementation(() => {}); beforeEach(() => {
generateIssues(component); wrapper = createComponent({
component.$refs.list.dispatchEvent(new Event('scroll')); listProps: { issuesCount: 25 },
return waitForPromises().then(() => {
expect(component.list.nextPage).toHaveBeenCalled();
}); });
}); });
it('does not load issues if already loading', () => { it('loads more issues after scrolling', () => {
component.list.nextPage = jest wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
.spyOn(component.list, 'nextPage')
.mockReturnValue(new Promise(() => {}));
component.onScroll();
component.onScroll();
return waitForPromises().then(() => { expect(actions.fetchIssuesForList).toHaveBeenCalled();
expect(component.list.nextPage).toHaveBeenCalledTimes(1);
});
}); });
it('shows loading more spinner', () => { it('does not load issues if already loading', () => {
component.showCount = true; wrapper = createComponent({
component.list.loadingMore = true; state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
return Vue.nextTick().then(() => {
expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
}); });
}); wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
});
describe('When Collapsed', () => { expect(actions.fetchIssuesForList).not.toHaveBeenCalled();
beforeEach((done) => {
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
({ mock, component } = createComponent({
done,
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
}));
generateIssues(component);
component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
}); });
afterEach(() => { it('shows loading more spinner', async () => {
mock.restore(); wrapper = createComponent({
component.$destroy(); state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
});
it('does not load all issues', () => {
return waitForPromises().then(() => {
// Initial getIssues from list constructor
expect(getIssues).toHaveBeenCalledTimes(1);
}); });
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
}); });
}); });
describe('max issue count warning', () => { describe('max issue count warning', () => {
beforeEach((done) => { beforeEach(() => {
({ mock, component } = createComponent({ wrapper = createComponent({
done, listProps: { issuesCount: 50 },
listProps: { type: 'closed', collapsed: true, issuesSize: 50 }, });
}));
});
afterEach(() => {
mock.restore();
component.$destroy();
}); });
describe('when issue count exceeds max issue count', () => { describe('when issue count exceeds max issue count', () => {
it('sets background to bg-danger-100', () => { it('sets background to bg-danger-100', async () => {
component.list.issuesSize = 4; wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => { await wrapper.vm.$nextTick();
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull(); expect(wrapper.find('.bg-danger-100').exists()).toBe(true);
});
}); });
}); });
describe('when list issue count does NOT exceed list max issue count', () => { describe('when list issue count does NOT exceed list max issue count', () => {
it('does not sets background to bg-danger-100', () => { it('does not sets background to bg-danger-100', () => {
component.list.issuesSize = 2; wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
component.list.maxIssueCount = 3;
return Vue.nextTick().then(() => { expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
});
}); });
}); });
describe('when list max issue count is 0', () => { describe('when list max issue count is 0', () => {
it('does not sets background to bg-danger-100', () => { it('does not sets background to bg-danger-100', () => {
component.list.maxIssueCount = 0; wrapper.setProps({ list: { maxIssueCount: 0 } });
expect(wrapper.find('.bg-danger-100').exists()).toBe(false);
});
});
});
return Vue.nextTick().then(() => { describe('drag & drop issue', () => {
expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); beforeEach(() => {
wrapper = createComponent();
});
describe('handleDragOnStart', () => {
it('adds a class `is-dragging` to document body', () => {
expect(document.body.classList.contains('is-dragging')).toBe(false);
findByTestId('tree-root-wrapper').vm.$emit('start');
expect(document.body.classList.contains('is-dragging')).toBe(true);
});
});
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', {
oldIndex: 1,
newIndex: 0,
item: {
dataset: {
issueId: mockIssues[0].id,
issueIid: mockIssues[0].iid,
issuePath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },
from: { dataset: { listId: 'gid://gitlab/List/2' } },
}); });
expect(document.body.classList.contains('is-dragging')).toBe(false);
}); });
}); });
}); });
......
...@@ -4,7 +4,7 @@ import Vue from 'vue'; ...@@ -4,7 +4,7 @@ import Vue from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import boardNewIssue from '~/boards/components/board_new_issue.vue'; import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
import '~/boards/models/list'; import '~/boards/models/list';
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
import BoardColumn from '~/boards/components/board_column_new.vue'; import Board from '~/boards/components/board_column_deprecated.vue';
import List from '~/boards/models/list';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import { createStore } from '~/boards/stores'; import axios from '~/lib/utils/axios_utils';
describe('Board Column Component', () => { describe('Board Column Component', () => {
let wrapper; let wrapper;
let store; let axiosMock;
beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear();
}); });
const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { const createComponent = ({
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
} = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...listObj, ...listObj,
listType, list_type: listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.assignee = {}; listMock.user = {};
} }
store = createStore(); // Making List reactive
const list = Vue.observable(new List(listMock));
wrapper = shallowMount(BoardColumn, { if (withLocalStorage) {
store, localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
wrapper = shallowMount(Board, {
propsData: { propsData: {
boardId,
disabled: false, disabled: false,
list: listMock, list,
}, },
provide: { provide: {
boardId, boardId,
...@@ -57,7 +82,7 @@ describe('Board Column Component', () => { ...@@ -57,7 +82,7 @@ describe('Board Column Component', () => {
it('has class is-collapsed when list is collapsed', () => { it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false }); createComponent({ collapsed: false });
expect(isCollapsed()).toBe(false); expect(wrapper.vm.list.isExpanded).toBe(true);
}); });
it('does not have class is-collapsed when list is expanded', () => { it('does not have class is-collapsed when list is expanded', () => {
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data'; import { listObj } from 'jest/boards/mock_data';
import Board from '~/boards/components/board_column.vue'; import BoardColumn from '~/boards/components/board_column.vue';
import List from '~/boards/models/list';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils'; import { createStore } from '~/boards/stores';
describe('Board Column Component', () => { describe('Board Column Component', () => {
let wrapper; let wrapper;
let axiosMock; let store;
beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear();
}); });
const createComponent = ({ const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
listType = ListType.backlog,
collapsed = false,
withLocalStorage = true,
} = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...listObj, ...listObj,
list_type: listType, listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.user = {}; listMock.assignee = {};
} }
// Making List reactive store = createStore();
const list = Vue.observable(new List(listMock));
if (withLocalStorage) { wrapper = shallowMount(BoardColumn, {
localStorage.setItem( store,
`boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(),
);
}
wrapper = shallowMount(Board, {
propsData: { propsData: {
boardId,
disabled: false, disabled: false,
list, list: listMock,
}, },
provide: { provide: {
boardId, boardId,
...@@ -82,7 +57,7 @@ describe('Board Column Component', () => { ...@@ -82,7 +57,7 @@ describe('Board Column Component', () => {
it('has class is-collapsed when list is collapsed', () => { it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false }); createComponent({ collapsed: false });
expect(wrapper.vm.list.isExpanded).toBe(true); expect(isCollapsed()).toBe(false);
}); });
it('does not have class is-collapsed when list is expanded', () => { it('does not have class is-collapsed when list is expanded', () => {
......
...@@ -4,7 +4,7 @@ import { GlAlert } from '@gitlab/ui'; ...@@ -4,7 +4,7 @@ import { GlAlert } from '@gitlab/ui';
import Draggable from 'vuedraggable'; import Draggable from 'vuedraggable';
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
import getters from 'ee_else_ce/boards/stores/getters'; import getters from 'ee_else_ce/boards/stores/getters';
import BoardColumn from '~/boards/components/board_column.vue'; import BoardColumnDeprecated from '~/boards/components/board_column_deprecated.vue';
import { mockLists, mockListsWithModel } from '../mock_data'; import { mockLists, mockListsWithModel } from '../mock_data';
import BoardContent from '~/boards/components/board_content.vue'; import BoardContent from '~/boards/components/board_content.vue';
...@@ -17,6 +17,7 @@ const actions = { ...@@ -17,6 +17,7 @@ const actions = {
describe('BoardContent', () => { describe('BoardContent', () => {
let wrapper; let wrapper;
window.gon = {};
const defaultState = { const defaultState = {
isShowingEpicsSwimlanes: false, isShowingEpicsSwimlanes: false,
...@@ -56,10 +57,12 @@ describe('BoardContent', () => { ...@@ -56,10 +57,12 @@ describe('BoardContent', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders a BoardColumn component per list', () => { it('renders a BoardColumnDeprecated component per list', () => {
createComponent(); createComponent();
expect(wrapper.findAll(BoardColumn)).toHaveLength(mockLists.length); expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength(
mockListsWithModel.length,
);
}); });
it('does not display EpicsSwimlanes component', () => { it('does not display EpicsSwimlanes component', () => {
...@@ -70,6 +73,13 @@ describe('BoardContent', () => { ...@@ -70,6 +73,13 @@ describe('BoardContent', () => {
}); });
describe('graphqlBoardLists feature flag enabled', () => { describe('graphqlBoardLists feature flag enabled', () => {
beforeEach(() => {
createComponent({ graphqlBoardListsEnabled: true });
gon.features = {
graphqlBoardLists: true,
};
});
describe('can admin list', () => { describe('can admin list', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } }); createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } });
...@@ -85,7 +95,7 @@ describe('BoardContent', () => { ...@@ -85,7 +95,7 @@ describe('BoardContent', () => {
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } }); createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } });
}); });
it('renders draggable component', () => { it('does not render draggable component', () => {
expect(wrapper.find(Draggable).exists()).toBe(false); expect(wrapper.find(Draggable).exists()).toBe(false);
}); });
}); });
......
import Vuex from 'vuex'; import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header_new.vue'; import { TEST_HOST } from 'helpers/test_constants';
import { listObj } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue';
import List from '~/boards/models/list';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Board List Header Component', () => { describe('Board List Header Component', () => {
let wrapper; let wrapper;
let store; let axiosMock;
const updateListSpy = jest.fn(); beforeEach(() => {
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear(); localStorage.clear();
}); });
...@@ -26,54 +31,45 @@ describe('Board List Header Component', () => { ...@@ -26,54 +31,45 @@ describe('Board List Header Component', () => {
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
withLocalStorage = true, withLocalStorage = true,
currentUserId = null,
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...mockLabelList, ...listObj,
listType, list_type: listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.assignee = {}; listMock.user = {};
} }
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) { if (withLocalStorage) {
localStorage.setItem( localStorage.setItem(
`boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`, `boards.${boardId}.${list.type}.${list.id}.expanded`,
(!collapsed).toString(), (!collapsed).toString(),
); );
} }
store = new Vuex.Store({
state: {},
actions: { updateList: updateListSpy },
getters: {},
});
wrapper = shallowMount(BoardListHeader, { wrapper = shallowMount(BoardListHeader, {
store,
localVue,
propsData: { propsData: {
disabled: false, disabled: false,
list: listMock, list,
}, },
provide: { provide: {
boardId, boardId,
weightFeatureAvailable: false,
currentUserId,
}, },
}); });
}; };
const isCollapsed = () => wrapper.vm.list.collapsed; const isCollapsed = () => !wrapper.props().list.isExpanded;
const isExpanded = () => !isCollapsed; const isExpanded = () => wrapper.vm.list.isExpanded;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.find('.board-title-caret'); const findCaret = () => wrapper.find('.board-title-caret');
describe('Add issue button', () => { describe('Add issue button', () => {
...@@ -93,8 +89,6 @@ describe('Board List Header Component', () => { ...@@ -93,8 +89,6 @@ describe('Board List Header Component', () => {
}); });
it('has a test for each list type', () => { it('has a test for each list type', () => {
createComponent();
Object.values(ListType).forEach((value) => { Object.values(ListType).forEach((value) => {
expect([...hasAddButton, ...hasNoAddButton]).toContain(value); expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
}); });
...@@ -108,80 +102,64 @@ describe('Board List Header Component', () => { ...@@ -108,80 +102,64 @@ describe('Board List Header Component', () => {
}); });
describe('expanding / collapsing the column', () => { describe('expanding / collapsing the column', () => {
it('does not collapse when clicking the header', async () => { it('does not collapse when clicking the header', () => {
createComponent(); createComponent();
expect(isCollapsed()).toBe(false); expect(isCollapsed()).toBe(false);
wrapper.find('[data-testid="board-list-header"]').trigger('click'); wrapper.find('[data-testid="board-list-header"]').trigger('click');
await wrapper.vm.$nextTick(); return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
expect(isCollapsed()).toBe(false); });
}); });
it('collapses expanded Column when clicking the collapse icon', async () => { it('collapses expanded Column when clicking the collapse icon', () => {
createComponent(); createComponent();
expect(isCollapsed()).toBe(false); expect(isExpanded()).toBe(true);
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick(); return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(true);
expect(isCollapsed()).toBe(true); });
}); });
it('expands collapsed Column when clicking the expand icon', async () => { it('expands collapsed Column when clicking the expand icon', () => {
createComponent({ collapsed: true }); createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true); expect(isCollapsed()).toBe(true);
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick(); return wrapper.vm.$nextTick().then(() => {
expect(isCollapsed()).toBe(false);
expect(isCollapsed()).toBe(false); });
}); });
it("when logged in it calls list update and doesn't set localStorage", async () => { it("when logged in it calls list update and doesn't set localStorage", () => {
createComponent({ withLocalStorage: false, currentUserId: 1 }); jest.spyOn(List.prototype, 'update');
window.gon.current_user_id = 1;
findCaret().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(updateListSpy).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
});
it("when logged out it doesn't call list update and sets localStorage", async () => { createComponent({ withLocalStorage: false });
createComponent();
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(updateListSpy).not.toHaveBeenCalled(); return wrapper.vm.$nextTick().then(() => {
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
});
}); });
});
describe('user can drag', () => { it("when logged out it doesn't call list update and sets localStorage", () => {
const cannotDragList = [ListType.backlog, ListType.closed]; jest.spyOn(List.prototype, 'update');
const canDragList = [ListType.label, ListType.milestone, ListType.assignee];
it.each(cannotDragList)( createComponent();
'does not have user-can-drag-class so user cannot drag list',
(listType) => {
createComponent({ listType });
expect(findTitle().classes()).not.toContain('user-can-drag');
},
);
it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => { findCaret().vm.$emit('click');
createComponent({ listType });
expect(findTitle().classes()).toContain('user-can-drag'); return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
});
}); });
}); });
}); });
import Vue from 'vue'; import Vuex from 'vuex';
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants'; import { mockLabelList } from 'jest/boards/mock_data';
import { listObj } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue'; import BoardListHeader from '~/boards/components/board_list_header.vue';
import List from '~/boards/models/list';
import { ListType } from '~/boards/constants'; import { ListType } from '~/boards/constants';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Board List Header Component', () => { describe('Board List Header Component', () => {
let wrapper; let wrapper;
let axiosMock; let store;
beforeEach(() => { const updateListSpy = jest.fn();
window.gon = {};
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
});
afterEach(() => { afterEach(() => {
axiosMock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null;
localStorage.clear(); localStorage.clear();
}); });
...@@ -31,45 +26,54 @@ describe('Board List Header Component', () => { ...@@ -31,45 +26,54 @@ describe('Board List Header Component', () => {
listType = ListType.backlog, listType = ListType.backlog,
collapsed = false, collapsed = false,
withLocalStorage = true, withLocalStorage = true,
currentUserId = null,
} = {}) => { } = {}) => {
const boardId = '1'; const boardId = '1';
const listMock = { const listMock = {
...listObj, ...mockLabelList,
list_type: listType, listType,
collapsed, collapsed,
}; };
if (listType === ListType.assignee) { if (listType === ListType.assignee) {
delete listMock.label; delete listMock.label;
listMock.user = {}; listMock.assignee = {};
} }
// Making List reactive
const list = Vue.observable(new List(listMock));
if (withLocalStorage) { if (withLocalStorage) {
localStorage.setItem( localStorage.setItem(
`boards.${boardId}.${list.type}.${list.id}.expanded`, `boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
(!collapsed).toString(), (!collapsed).toString(),
); );
} }
store = new Vuex.Store({
state: {},
actions: { updateList: updateListSpy },
getters: {},
});
wrapper = shallowMount(BoardListHeader, { wrapper = shallowMount(BoardListHeader, {
store,
localVue,
propsData: { propsData: {
disabled: false, disabled: false,
list, list: listMock,
}, },
provide: { provide: {
boardId, boardId,
weightFeatureAvailable: false,
currentUserId,
}, },
}); });
}; };
const isCollapsed = () => !wrapper.props().list.isExpanded; const isCollapsed = () => wrapper.vm.list.collapsed;
const isExpanded = () => wrapper.vm.list.isExpanded; const isExpanded = () => !isCollapsed;
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' }); const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
const findTitle = () => wrapper.find('.board-title');
const findCaret = () => wrapper.find('.board-title-caret'); const findCaret = () => wrapper.find('.board-title-caret');
describe('Add issue button', () => { describe('Add issue button', () => {
...@@ -89,6 +93,8 @@ describe('Board List Header Component', () => { ...@@ -89,6 +93,8 @@ describe('Board List Header Component', () => {
}); });
it('has a test for each list type', () => { it('has a test for each list type', () => {
createComponent();
Object.values(ListType).forEach((value) => { Object.values(ListType).forEach((value) => {
expect([...hasAddButton, ...hasNoAddButton]).toContain(value); expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
}); });
...@@ -102,64 +108,80 @@ describe('Board List Header Component', () => { ...@@ -102,64 +108,80 @@ describe('Board List Header Component', () => {
}); });
describe('expanding / collapsing the column', () => { describe('expanding / collapsing the column', () => {
it('does not collapse when clicking the header', () => { it('does not collapse when clicking the header', async () => {
createComponent(); createComponent();
expect(isCollapsed()).toBe(false); expect(isCollapsed()).toBe(false);
wrapper.find('[data-testid="board-list-header"]').trigger('click'); wrapper.find('[data-testid="board-list-header"]').trigger('click');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(isCollapsed()).toBe(false);
}); expect(isCollapsed()).toBe(false);
}); });
it('collapses expanded Column when clicking the collapse icon', () => { it('collapses expanded Column when clicking the collapse icon', async () => {
createComponent(); createComponent();
expect(isExpanded()).toBe(true); expect(isCollapsed()).toBe(false);
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(isCollapsed()).toBe(true);
}); expect(isCollapsed()).toBe(true);
}); });
it('expands collapsed Column when clicking the expand icon', () => { it('expands collapsed Column when clicking the expand icon', async () => {
createComponent({ collapsed: true }); createComponent({ collapsed: true });
expect(isCollapsed()).toBe(true); expect(isCollapsed()).toBe(true);
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(isCollapsed()).toBe(false);
});
});
it("when logged in it calls list update and doesn't set localStorage", () => { expect(isCollapsed()).toBe(false);
jest.spyOn(List.prototype, 'update'); });
window.gon.current_user_id = 1;
createComponent({ withLocalStorage: false }); it("when logged in it calls list update and doesn't set localStorage", async () => {
createComponent({ withLocalStorage: false, currentUserId: 1 });
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick();
return wrapper.vm.$nextTick().then(() => { expect(updateListSpy).toHaveBeenCalledTimes(1);
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
});
}); });
it("when logged out it doesn't call list update and sets localStorage", () => { it("when logged out it doesn't call list update and sets localStorage", async () => {
jest.spyOn(List.prototype, 'update');
createComponent(); createComponent();
findCaret().vm.$emit('click'); findCaret().vm.$emit('click');
await wrapper.vm.$nextTick();
return wrapper.vm.$nextTick().then(() => { expect(updateListSpy).not.toHaveBeenCalled();
expect(wrapper.vm.list.update).not.toHaveBeenCalled(); expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); });
}); });
describe('user can drag', () => {
const cannotDragList = [ListType.backlog, ListType.closed];
const canDragList = [ListType.label, ListType.milestone, ListType.assignee];
it.each(cannotDragList)(
'does not have user-can-drag-class so user cannot drag list',
(listType) => {
createComponent({ listType });
expect(findTitle().classes()).not.toContain('user-can-drag');
},
);
it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => {
createComponent({ listType });
expect(findTitle().classes()).toContain('user-can-drag');
}); });
}); });
}); });
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import BoardNewIssue from '~/boards/components/board_new_issue_new.vue'; import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import '~/boards/models/list'; import '~/boards/models/list';
import { mockList, mockGroupProjects } from '../mock_data'; import { mockList, mockGroupProjects } from '../mock_data';
......
...@@ -285,7 +285,7 @@ export const setMockEndpoints = (opts = {}) => { ...@@ -285,7 +285,7 @@ export const setMockEndpoints = (opts = {}) => {
export const mockList = { export const mockList = {
id: 'gid://gitlab/List/1', id: 'gid://gitlab/List/1',
title: 'Backlog', title: 'Backlog',
position: null, position: -Infinity,
listType: 'backlog', listType: 'backlog',
collapsed: false, collapsed: false,
label: null, label: null,
......
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