Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
2931472c10
commit
d7432b66ff
44 changed files with 2056 additions and 1650 deletions
|
@ -88,13 +88,33 @@ export function fullIterationId(id) {
|
|||
return `gid://gitlab/Iteration/${id}`;
|
||||
}
|
||||
|
||||
export function fullUserId(id) {
|
||||
return `gid://gitlab/User/${id}`;
|
||||
}
|
||||
|
||||
export function fullMilestoneId(id) {
|
||||
return `gid://gitlab/Milestone/${id}`;
|
||||
}
|
||||
|
||||
export function fullLabelId(label) {
|
||||
if (label.project_id !== null) {
|
||||
if (label.project_id && label.project_id !== null) {
|
||||
return `gid://gitlab/ProjectLabel/${label.id}`;
|
||||
}
|
||||
return `gid://gitlab/GroupLabel/${label.id}`;
|
||||
}
|
||||
|
||||
export function formatIssueInput(issueInput, boardConfig) {
|
||||
const { labelIds = [], assigneeIds = [] } = issueInput;
|
||||
const { labels, assigneeId, milestoneId } = boardConfig;
|
||||
|
||||
return {
|
||||
milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null,
|
||||
...issueInput,
|
||||
labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])],
|
||||
assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
export function moveIssueListHelper(issue, fromList, toList) {
|
||||
const updatedIssue = issue;
|
||||
if (
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
<script>
|
||||
// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards
|
||||
import Sortable from 'sortablejs';
|
||||
import { mapGetters, mapActions, mapState } from 'vuex';
|
||||
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
|
||||
import BoardList from './board_list.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
|
||||
import { isListDraggable } from '../boards_util';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -32,53 +30,27 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
detailIssue: boardsStore.detail,
|
||||
filter: boardsStore.filter,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['filterParams']),
|
||||
...mapGetters(['getIssuesByList']),
|
||||
listIssues() {
|
||||
return this.list.issues;
|
||||
return this.getIssuesByList(this.list.id);
|
||||
},
|
||||
isListDraggable() {
|
||||
return isListDraggable(this.list);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filter: {
|
||||
filterParams: {
|
||||
handler() {
|
||||
this.list.page = 1;
|
||||
this.list.getIssues(true).catch(() => {
|
||||
// TODO: handle request error
|
||||
});
|
||||
this.fetchIssuesForList({ listId: this.list.id });
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
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);
|
||||
methods: {
|
||||
...mapActions(['fetchIssuesForList']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -86,20 +58,25 @@ export default {
|
|||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'is-draggable': !list.preset,
|
||||
'is-expandable': list.isExpandable,
|
||||
'is-collapsed': !list.isExpanded,
|
||||
'board-type-assignee': list.type === 'assignee',
|
||||
'is-draggable': isListDraggable,
|
||||
'is-collapsed': list.collapsed,
|
||||
'board-type-assignee': list.listType === 'assignee',
|
||||
}"
|
||||
: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"
|
||||
>
|
||||
<div
|
||||
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 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>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
<script>
|
||||
// This component is being replaced in favor of './board_column.vue' for GraphQL boards
|
||||
import Sortable from 'sortablejs';
|
||||
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
|
||||
import BoardList from './board_list_deprecated.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BoardListHeader,
|
||||
BoardList,
|
||||
},
|
||||
inject: {
|
||||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
canAdminList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
detailIssue: boardsStore.detail,
|
||||
filter: boardsStore.filter,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
listIssues() {
|
||||
return this.list.issues;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filter: {
|
||||
handler() {
|
||||
this.list.page = 1;
|
||||
this.list.getIssues(true).catch(() => {
|
||||
// TODO: handle request error
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'is-draggable': !list.preset,
|
||||
'is-expandable': list.isExpandable,
|
||||
'is-collapsed': !list.isExpanded,
|
||||
'board-type-assignee': list.type === 'assignee',
|
||||
}"
|
||||
:data-id="list.id"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
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 ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,82 +0,0 @@
|
|||
<script>
|
||||
import { mapGetters, mapActions, mapState } from 'vuex';
|
||||
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue';
|
||||
import BoardList from './board_list_new.vue';
|
||||
import { isListDraggable } from '../boards_util';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BoardListHeader,
|
||||
BoardList,
|
||||
},
|
||||
inject: {
|
||||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
canAdminList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(['filterParams']),
|
||||
...mapGetters(['getIssuesByList']),
|
||||
listIssues() {
|
||||
return this.getIssuesByList(this.list.id);
|
||||
},
|
||||
isListDraggable() {
|
||||
return isListDraggable(this.list);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filterParams: {
|
||||
handler() {
|
||||
this.fetchIssuesForList({ listId: this.list.id });
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchIssuesForList']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'is-draggable': isListDraggable,
|
||||
'is-collapsed': list.collapsed,
|
||||
'board-type-assignee': list.listType === 'assignee',
|
||||
}"
|
||||
: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"
|
||||
data-qa-selector="board_list"
|
||||
>
|
||||
<div
|
||||
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
|
||||
ref="board-list"
|
||||
:disabled="disabled"
|
||||
:issues="listIssues"
|
||||
:list="list"
|
||||
:can-admin-list="canAdminList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -3,15 +3,15 @@ import Draggable from 'vuedraggable';
|
|||
import { mapState, mapGetters, mapActions } from 'vuex';
|
||||
import { sortBy } from 'lodash';
|
||||
import { GlAlert } from '@gitlab/ui';
|
||||
import BoardColumnDeprecated from './board_column_deprecated.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 defaultSortableConfig from '~/sortable/sortable_config';
|
||||
import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn,
|
||||
BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated,
|
||||
BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
|
||||
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
|
||||
GlAlert,
|
||||
|
@ -20,7 +20,8 @@ export default {
|
|||
props: {
|
||||
lists: {
|
||||
type: Array,
|
||||
required: true,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
canAdminList: {
|
||||
type: Boolean,
|
||||
|
@ -53,7 +54,7 @@ export default {
|
|||
fallbackOnBody: false,
|
||||
group: 'boards-list',
|
||||
tag: 'div',
|
||||
value: this.lists,
|
||||
value: this.boardListsToUse,
|
||||
};
|
||||
|
||||
return this.canDragColumns ? options : {};
|
||||
|
|
|
@ -1,27 +1,24 @@
|
|||
<script>
|
||||
import { Sortable, MultiDrag } from 'sortablejs';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import boardNewIssue from './board_new_issue.vue';
|
||||
import boardCard from './board_card.vue';
|
||||
import defaultSortableConfig from '~/sortable/sortable_config';
|
||||
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 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_new.vue' for GraphQL boards
|
||||
|
||||
Sortable.mount(new MultiDrag());
|
||||
|
||||
export default {
|
||||
name: 'BoardList',
|
||||
i18n: {
|
||||
loadingIssues: __('Loading issues'),
|
||||
loadingMoreissues: __('Loading more issues'),
|
||||
showingAllIssues: __('Showing all issues'),
|
||||
},
|
||||
components: {
|
||||
boardCard,
|
||||
boardNewIssue,
|
||||
BoardCard,
|
||||
BoardNewIssue,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
props: {
|
||||
|
@ -37,55 +34,67 @@ export default {
|
|||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
canAdminList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollOffset: 250,
|
||||
filters: boardsStore.state.filters,
|
||||
showCount: false,
|
||||
showIssueForm: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['pageInfoByListId', 'listsFlags']),
|
||||
paginatedIssueText() {
|
||||
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
|
||||
pageSize: this.list.issues.length,
|
||||
total: this.list.issuesSize,
|
||||
pageSize: this.issues.length,
|
||||
total: this.list.issuesCount,
|
||||
});
|
||||
},
|
||||
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() {
|
||||
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: {
|
||||
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;
|
||||
}
|
||||
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -94,315 +103,90 @@ export default {
|
|||
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
|
||||
},
|
||||
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
|
||||
this.$refs.list.addEventListener('scroll', this.onScroll);
|
||||
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.$refs.list.removeEventListener('scroll', this.onScroll);
|
||||
this.listRef.removeEventListener('scroll', this.onScroll);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchIssuesForList', 'moveIssue']),
|
||||
listHeight() {
|
||||
return this.$refs.list.getBoundingClientRect().height;
|
||||
return this.listRef.getBoundingClientRect().height;
|
||||
},
|
||||
scrollHeight() {
|
||||
return this.$refs.list.scrollHeight;
|
||||
return this.listRef.scrollHeight;
|
||||
},
|
||||
scrollTop() {
|
||||
return this.$refs.list.scrollTop + this.listHeight();
|
||||
return this.listRef.scrollTop + this.listHeight();
|
||||
},
|
||||
scrollToTop() {
|
||||
this.$refs.list.scrollTop = 0;
|
||||
this.listRef.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);
|
||||
}
|
||||
this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
|
||||
},
|
||||
toggleForm() {
|
||||
this.showIssueForm = !this.showIssueForm;
|
||||
},
|
||||
onScroll() {
|
||||
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
|
||||
this.loadNextPage();
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -410,21 +194,31 @@ export default {
|
|||
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
|
||||
class="board-list-component position-relative h-100"
|
||||
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="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 />
|
||||
</div>
|
||||
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
|
||||
<ul
|
||||
<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.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"
|
||||
: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"
|
||||
|
@ -435,11 +229,11 @@ export default {
|
|||
: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>
|
||||
<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>
|
||||
</ul>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,443 @@
|
|||
<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 {
|
|||
GlSprintf,
|
||||
GlTooltipDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { n__, s__ } from '~/locale';
|
||||
import { n__, s__, __ } from '~/locale';
|
||||
import AccessorUtilities from '../../lib/utils/accessor';
|
||||
import IssueCount from './issue_count.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import eventHub from '../eventhub';
|
||||
import sidebarEventHub from '~/sidebar/event_hub';
|
||||
import { inactiveId, LIST, ListType } from '../constants';
|
||||
import { isScopedLabel } from '~/lib/utils/common_utils';
|
||||
import { isListDraggable } from '~/boards/boards_util';
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
newIssue: __('New issue'),
|
||||
listSettings: __('List settings'),
|
||||
expand: s__('Boards|Expand'),
|
||||
collapse: s__('Boards|Collapse'),
|
||||
},
|
||||
components: {
|
||||
GlButtonGroup,
|
||||
GlButton,
|
||||
|
@ -35,6 +41,15 @@ export default {
|
|||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
weightFeatureAvailable: {
|
||||
default: false,
|
||||
},
|
||||
scopedLabelsAvailable: {
|
||||
default: false,
|
||||
},
|
||||
currentUserId: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
|
@ -52,56 +67,53 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
weightFeatureAvailable: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['activeId']),
|
||||
isLoggedIn() {
|
||||
return Boolean(gon.current_user_id);
|
||||
return Boolean(this.currentUserId);
|
||||
},
|
||||
listType() {
|
||||
return this.list.type;
|
||||
return this.list.listType;
|
||||
},
|
||||
listAssignee() {
|
||||
return this.list?.assignee?.username || '';
|
||||
},
|
||||
listTitle() {
|
||||
return this.list?.label?.description || this.list.title || '';
|
||||
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
|
||||
},
|
||||
showListHeaderButton() {
|
||||
return !this.disabled && this.listType !== ListType.closed;
|
||||
},
|
||||
showMilestoneListDetails() {
|
||||
return (
|
||||
this.list.type === 'milestone' &&
|
||||
this.listType === ListType.milestone &&
|
||||
this.list.milestone &&
|
||||
(this.list.isExpanded || !this.isSwimlanesHeader)
|
||||
(!this.list.collapsed || !this.isSwimlanesHeader)
|
||||
);
|
||||
},
|
||||
showAssigneeListDetails() {
|
||||
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
|
||||
return (
|
||||
this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
|
||||
);
|
||||
},
|
||||
issuesCount() {
|
||||
return this.list.issuesSize;
|
||||
return this.list.issuesCount;
|
||||
},
|
||||
issuesTooltipLabel() {
|
||||
return n__(`%d issue`, `%d issues`, this.issuesCount);
|
||||
},
|
||||
chevronTooltip() {
|
||||
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
|
||||
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
|
||||
},
|
||||
chevronIcon() {
|
||||
return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
|
||||
return this.list.collapsed ? 'chevron-down' : 'chevron-right';
|
||||
},
|
||||
isNewIssueShown() {
|
||||
return this.listType === ListType.backlog || this.showListHeaderButton;
|
||||
},
|
||||
isSettingsShown() {
|
||||
return (
|
||||
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
|
||||
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
|
||||
);
|
||||
},
|
||||
uniqueKey() {
|
||||
|
@ -111,9 +123,15 @@ export default {
|
|||
collapsedTooltipTitle() {
|
||||
return this.listTitle || this.listAssignee;
|
||||
},
|
||||
headerStyle() {
|
||||
return { borderTopColor: this.list?.label?.color };
|
||||
},
|
||||
userCanDrag() {
|
||||
return !this.disabled && isListDraggable(this.list);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setActiveId']),
|
||||
...mapActions(['updateList', 'setActiveId']),
|
||||
openSidebarSettings() {
|
||||
if (this.activeId === inactiveId) {
|
||||
sidebarEventHub.$emit('sidebar.closeAll');
|
||||
|
@ -122,14 +140,14 @@ export default {
|
|||
this.setActiveId({ id: this.list.id, sidebarType: LIST });
|
||||
},
|
||||
showScopedLabels(label) {
|
||||
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
|
||||
return this.scopedLabelsAvailable && isScopedLabel(label);
|
||||
},
|
||||
|
||||
showNewIssueForm() {
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
toggleExpanded() {
|
||||
this.list.isExpanded = !this.list.isExpanded;
|
||||
this.list.collapsed = !this.list.collapsed;
|
||||
|
||||
if (!this.isLoggedIn) {
|
||||
this.addToLocalStorage();
|
||||
|
@ -143,11 +161,11 @@ export default {
|
|||
},
|
||||
addToLocalStorage() {
|
||||
if (AccessorUtilities.isLocalStorageAccessSafe()) {
|
||||
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
|
||||
localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
|
||||
}
|
||||
},
|
||||
updateListFunction() {
|
||||
this.list.update();
|
||||
this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -157,26 +175,25 @@ export default {
|
|||
<header
|
||||
:class="{
|
||||
'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,
|
||||
}"
|
||||
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
|
||||
:style="headerStyle"
|
||||
class="board-header gl-relative"
|
||||
data-qa-selector="board_list_header"
|
||||
data-testid="board-list-header"
|
||||
>
|
||||
<h3
|
||||
:class="{
|
||||
'user-can-drag': !disabled && !list.preset,
|
||||
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
|
||||
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
|
||||
'gl-py-2': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-flex-direction-column': !list.isExpanded,
|
||||
'user-can-drag': userCanDrag,
|
||||
'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
|
||||
'gl-border-b-0': list.collapsed || isSwimlanesHeader,
|
||||
'gl-py-2': list.collapsed && isSwimlanesHeader,
|
||||
'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"
|
||||
>
|
||||
<gl-button
|
||||
v-if="list.isExpandable"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="chevronTooltip"
|
||||
:title="chevronTooltip"
|
||||
|
@ -186,14 +203,14 @@ export default {
|
|||
size="small"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<!-- The following is only true in EE and if it is a milestone -->
|
||||
<!-- EE start -->
|
||||
<span
|
||||
v-if="showMilestoneListDetails"
|
||||
aria-hidden="true"
|
||||
class="milestone-icon"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
'gl-mr-2': list.isExpanded,
|
||||
'gl-mt-3 gl-rotate-90': list.collapsed,
|
||||
'gl-mr-2': !list.collapsed,
|
||||
}"
|
||||
>
|
||||
<gl-icon name="timer" />
|
||||
|
@ -201,90 +218,95 @@ export default {
|
|||
|
||||
<a
|
||||
v-if="showAssigneeListDetails"
|
||||
:href="list.assignee.path"
|
||||
:href="list.assignee.webUrl"
|
||||
class="user-avatar-link js-no-trigger"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
'gl-mt-3 gl-rotate-90': list.collapsed,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-gl-tooltip.hover.bottom
|
||||
:title="listAssignee"
|
||||
:alt="list.assignee.name"
|
||||
:src="list.assignee.avatar"
|
||||
:src="list.assignee.avatarUrl"
|
||||
class="avatar s20"
|
||||
height="20"
|
||||
width="20"
|
||||
/>
|
||||
</a>
|
||||
<!-- EE end -->
|
||||
<div
|
||||
class="board-title-text"
|
||||
:class="{
|
||||
'gl-display-none': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
|
||||
'gl-flex-grow-1': list.isExpanded,
|
||||
'gl-display-none': list.collapsed && isSwimlanesHeader,
|
||||
'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
|
||||
'gl-flex-grow-1': !list.collapsed,
|
||||
}"
|
||||
>
|
||||
<!-- EE start -->
|
||||
<span
|
||||
v-if="list.type !== 'label'"
|
||||
v-if="listType !== 'label'"
|
||||
v-gl-tooltip.hover
|
||||
:class="{
|
||||
'gl-display-block': !list.isExpanded || list.type === 'milestone',
|
||||
'gl-display-block': list.collapsed || listType === 'milestone',
|
||||
}"
|
||||
:title="listTitle"
|
||||
class="board-title-main-text gl-text-truncate"
|
||||
>
|
||||
{{ list.title }}
|
||||
{{ listTitle }}
|
||||
</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-display-none': !list.isExpanded }"
|
||||
>
|
||||
@{{ listAssignee }}
|
||||
</span>
|
||||
<!-- EE end -->
|
||||
<gl-label
|
||||
v-if="list.type === 'label'"
|
||||
v-if="listType === 'label'"
|
||||
v-gl-tooltip.hover.bottom
|
||||
:background-color="list.label.color"
|
||||
:description="list.label.description"
|
||||
:scoped="showScopedLabels(list.label)"
|
||||
:size="!list.isExpanded ? 'sm' : ''"
|
||||
:size="list.collapsed ? 'sm' : ''"
|
||||
:title="list.label.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- EE start -->
|
||||
<span
|
||||
v-if="isSwimlanesHeader && !list.isExpanded"
|
||||
v-if="isSwimlanesHeader && list.collapsed"
|
||||
ref="collapsedInfo"
|
||||
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" />
|
||||
</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 v-if="list.maxIssueCount !== 0">
|
||||
•
|
||||
•
|
||||
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
|
||||
<template #issuesSize>{{ issuesTooltipLabel }}</template>
|
||||
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
<div v-else>• {{ issuesTooltipLabel }}</div>
|
||||
<div v-else>• {{ issuesTooltipLabel }}</div>
|
||||
<div v-if="weightFeatureAvailable">
|
||||
•
|
||||
•
|
||||
<gl-sprintf :message="__('%{totalWeight} total weight')">
|
||||
<template #totalWeight>{{ list.totalWeight }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
</gl-tooltip>
|
||||
<!-- EE end -->
|
||||
|
||||
<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="{
|
||||
'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-p-0': !list.isExpanded,
|
||||
'gl-display-none!': list.collapsed && isSwimlanesHeader,
|
||||
'gl-p-0': list.collapsed,
|
||||
}"
|
||||
>
|
||||
<span class="gl-display-inline-flex">
|
||||
|
@ -293,7 +315,7 @@ export default {
|
|||
<gl-icon class="gl-mr-2" name="issues" />
|
||||
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
|
||||
</span>
|
||||
<!-- The following is only true in EE. -->
|
||||
<!-- EE start -->
|
||||
<template v-if="weightFeatureAvailable">
|
||||
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
|
||||
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
|
||||
|
@ -301,6 +323,7 @@ export default {
|
|||
{{ list.totalWeight }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- EE end -->
|
||||
</span>
|
||||
</div>
|
||||
<gl-button-group
|
||||
|
@ -309,13 +332,11 @@ export default {
|
|||
>
|
||||
<gl-button
|
||||
v-if="isNewIssueShown"
|
||||
v-show="!list.collapsed"
|
||||
ref="newIssueBtn"
|
||||
v-gl-tooltip.hover
|
||||
:class="{
|
||||
'gl-display-none': !list.isExpanded,
|
||||
}"
|
||||
:aria-label="__('New issue')"
|
||||
:title="__('New issue')"
|
||||
:aria-label="$options.i18n.newIssue"
|
||||
:title="$options.i18n.newIssue"
|
||||
class="issue-count-badge-add-button no-drag"
|
||||
icon="plus"
|
||||
@click="showNewIssueForm"
|
||||
|
@ -325,13 +346,13 @@ export default {
|
|||
v-if="isSettingsShown"
|
||||
ref="settingsBtn"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="__('List settings')"
|
||||
:aria-label="$options.i18n.listSettings"
|
||||
class="no-drag js-board-settings-button"
|
||||
:title="__('List settings')"
|
||||
:title="$options.i18n.listSettings"
|
||||
icon="settings"
|
||||
@click="openSidebarSettings"
|
||||
/>
|
||||
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
|
||||
<gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
|
||||
</gl-button-group>
|
||||
</h3>
|
||||
</header>
|
||||
|
|
|
@ -9,22 +9,18 @@ import {
|
|||
GlSprintf,
|
||||
GlTooltipDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { n__, s__, __ } from '~/locale';
|
||||
import { n__, s__ } from '~/locale';
|
||||
import AccessorUtilities from '../../lib/utils/accessor';
|
||||
import IssueCount from './issue_count.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import eventHub from '../eventhub';
|
||||
import sidebarEventHub from '~/sidebar/event_hub';
|
||||
import { inactiveId, LIST, ListType } from '../constants';
|
||||
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 {
|
||||
i18n: {
|
||||
newIssue: __('New issue'),
|
||||
listSettings: __('List settings'),
|
||||
expand: s__('Boards|Expand'),
|
||||
collapse: s__('Boards|Collapse'),
|
||||
},
|
||||
components: {
|
||||
GlButtonGroup,
|
||||
GlButton,
|
||||
|
@ -41,15 +37,6 @@ export default {
|
|||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
weightFeatureAvailable: {
|
||||
default: false,
|
||||
},
|
||||
scopedLabelsAvailable: {
|
||||
default: false,
|
||||
},
|
||||
currentUserId: {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
|
@ -67,53 +54,56 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
weightFeatureAvailable: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['activeId']),
|
||||
isLoggedIn() {
|
||||
return Boolean(this.currentUserId);
|
||||
return Boolean(gon.current_user_id);
|
||||
},
|
||||
listType() {
|
||||
return this.list.listType;
|
||||
return this.list.type;
|
||||
},
|
||||
listAssignee() {
|
||||
return this.list?.assignee?.username || '';
|
||||
},
|
||||
listTitle() {
|
||||
return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
|
||||
return this.list?.label?.description || this.list.title || '';
|
||||
},
|
||||
showListHeaderButton() {
|
||||
return !this.disabled && this.listType !== ListType.closed;
|
||||
},
|
||||
showMilestoneListDetails() {
|
||||
return (
|
||||
this.listType === ListType.milestone &&
|
||||
this.list.type === 'milestone' &&
|
||||
this.list.milestone &&
|
||||
(!this.list.collapsed || !this.isSwimlanesHeader)
|
||||
(this.list.isExpanded || !this.isSwimlanesHeader)
|
||||
);
|
||||
},
|
||||
showAssigneeListDetails() {
|
||||
return (
|
||||
this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
|
||||
);
|
||||
return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader);
|
||||
},
|
||||
issuesCount() {
|
||||
return this.list.issuesCount;
|
||||
return this.list.issuesSize;
|
||||
},
|
||||
issuesTooltipLabel() {
|
||||
return n__(`%d issue`, `%d issues`, this.issuesCount);
|
||||
},
|
||||
chevronTooltip() {
|
||||
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
|
||||
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
|
||||
},
|
||||
chevronIcon() {
|
||||
return this.list.collapsed ? 'chevron-down' : 'chevron-right';
|
||||
return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
|
||||
},
|
||||
isNewIssueShown() {
|
||||
return this.listType === ListType.backlog || this.showListHeaderButton;
|
||||
},
|
||||
isSettingsShown() {
|
||||
return (
|
||||
this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
|
||||
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
|
||||
);
|
||||
},
|
||||
uniqueKey() {
|
||||
|
@ -123,15 +113,9 @@ export default {
|
|||
collapsedTooltipTitle() {
|
||||
return this.listTitle || this.listAssignee;
|
||||
},
|
||||
headerStyle() {
|
||||
return { borderTopColor: this.list?.label?.color };
|
||||
},
|
||||
userCanDrag() {
|
||||
return !this.disabled && isListDraggable(this.list);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['updateList', 'setActiveId']),
|
||||
...mapActions(['setActiveId']),
|
||||
openSidebarSettings() {
|
||||
if (this.activeId === inactiveId) {
|
||||
sidebarEventHub.$emit('sidebar.closeAll');
|
||||
|
@ -140,14 +124,14 @@ export default {
|
|||
this.setActiveId({ id: this.list.id, sidebarType: LIST });
|
||||
},
|
||||
showScopedLabels(label) {
|
||||
return this.scopedLabelsAvailable && isScopedLabel(label);
|
||||
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
|
||||
},
|
||||
|
||||
showNewIssueForm() {
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
toggleExpanded() {
|
||||
this.list.collapsed = !this.list.collapsed;
|
||||
this.list.isExpanded = !this.list.isExpanded;
|
||||
|
||||
if (!this.isLoggedIn) {
|
||||
this.addToLocalStorage();
|
||||
|
@ -161,11 +145,11 @@ export default {
|
|||
},
|
||||
addToLocalStorage() {
|
||||
if (AccessorUtilities.isLocalStorageAccessSafe()) {
|
||||
localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
|
||||
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
|
||||
}
|
||||
},
|
||||
updateListFunction() {
|
||||
this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
|
||||
this.list.update();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -175,25 +159,26 @@ export default {
|
|||
<header
|
||||
:class="{
|
||||
'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,
|
||||
}"
|
||||
:style="headerStyle"
|
||||
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
|
||||
class="board-header gl-relative"
|
||||
data-qa-selector="board_list_header"
|
||||
data-testid="board-list-header"
|
||||
>
|
||||
<h3
|
||||
:class="{
|
||||
'user-can-drag': userCanDrag,
|
||||
'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
|
||||
'gl-border-b-0': list.collapsed || isSwimlanesHeader,
|
||||
'gl-py-2': list.collapsed && isSwimlanesHeader,
|
||||
'gl-flex-direction-column': list.collapsed,
|
||||
'user-can-drag': !disabled && !list.preset,
|
||||
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
|
||||
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
|
||||
'gl-py-2': !list.isExpanded && isSwimlanesHeader,
|
||||
'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"
|
||||
>
|
||||
<gl-button
|
||||
v-if="list.isExpandable"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="chevronTooltip"
|
||||
:title="chevronTooltip"
|
||||
|
@ -203,14 +188,14 @@ export default {
|
|||
size="small"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<!-- EE start -->
|
||||
<!-- The following is only true in EE and if it is a milestone -->
|
||||
<span
|
||||
v-if="showMilestoneListDetails"
|
||||
aria-hidden="true"
|
||||
class="milestone-icon"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': list.collapsed,
|
||||
'gl-mr-2': !list.collapsed,
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
'gl-mr-2': list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<gl-icon name="timer" />
|
||||
|
@ -218,95 +203,90 @@ export default {
|
|||
|
||||
<a
|
||||
v-if="showAssigneeListDetails"
|
||||
:href="list.assignee.webUrl"
|
||||
:href="list.assignee.path"
|
||||
class="user-avatar-link js-no-trigger"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': list.collapsed,
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-gl-tooltip.hover.bottom
|
||||
:title="listAssignee"
|
||||
:alt="list.assignee.name"
|
||||
:src="list.assignee.avatarUrl"
|
||||
:src="list.assignee.avatar"
|
||||
class="avatar s20"
|
||||
height="20"
|
||||
width="20"
|
||||
/>
|
||||
</a>
|
||||
<!-- EE end -->
|
||||
<div
|
||||
class="board-title-text"
|
||||
:class="{
|
||||
'gl-display-none': list.collapsed && isSwimlanesHeader,
|
||||
'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
|
||||
'gl-flex-grow-1': !list.collapsed,
|
||||
'gl-display-none': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
|
||||
'gl-flex-grow-1': list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<!-- EE start -->
|
||||
<span
|
||||
v-if="listType !== 'label'"
|
||||
v-if="list.type !== 'label'"
|
||||
v-gl-tooltip.hover
|
||||
:class="{
|
||||
'gl-display-block': list.collapsed || listType === 'milestone',
|
||||
'gl-display-block': !list.isExpanded || list.type === 'milestone',
|
||||
}"
|
||||
:title="listTitle"
|
||||
class="board-title-main-text gl-text-truncate"
|
||||
>
|
||||
{{ listTitle }}
|
||||
{{ list.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="listType === 'assignee'"
|
||||
v-show="!list.collapsed"
|
||||
v-if="list.type === 'assignee'"
|
||||
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
|
||||
:class="{ 'gl-display-none': !list.isExpanded }"
|
||||
>
|
||||
@{{ listAssignee }}
|
||||
</span>
|
||||
<!-- EE end -->
|
||||
<gl-label
|
||||
v-if="listType === 'label'"
|
||||
v-if="list.type === 'label'"
|
||||
v-gl-tooltip.hover.bottom
|
||||
:background-color="list.label.color"
|
||||
:description="list.label.description"
|
||||
:scoped="showScopedLabels(list.label)"
|
||||
:size="list.collapsed ? 'sm' : ''"
|
||||
:size="!list.isExpanded ? 'sm' : ''"
|
||||
:title="list.label.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- EE start -->
|
||||
<span
|
||||
v-if="isSwimlanesHeader && list.collapsed"
|
||||
v-if="isSwimlanesHeader && !list.isExpanded"
|
||||
ref="collapsedInfo"
|
||||
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" />
|
||||
</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 v-if="list.maxIssueCount !== 0">
|
||||
•
|
||||
•
|
||||
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
|
||||
<template #issuesSize>{{ issuesTooltipLabel }}</template>
|
||||
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
<div v-else>• {{ issuesTooltipLabel }}</div>
|
||||
<div v-else>• {{ issuesTooltipLabel }}</div>
|
||||
<div v-if="weightFeatureAvailable">
|
||||
•
|
||||
•
|
||||
<gl-sprintf :message="__('%{totalWeight} total weight')">
|
||||
<template #totalWeight>{{ list.totalWeight }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
</gl-tooltip>
|
||||
<!-- EE end -->
|
||||
|
||||
<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="{
|
||||
'gl-display-none!': list.collapsed && isSwimlanesHeader,
|
||||
'gl-p-0': list.collapsed,
|
||||
'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-p-0': !list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="gl-display-inline-flex">
|
||||
|
@ -315,7 +295,7 @@ export default {
|
|||
<gl-icon class="gl-mr-2" name="issues" />
|
||||
<issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" />
|
||||
</span>
|
||||
<!-- EE start -->
|
||||
<!-- The following is only true in EE. -->
|
||||
<template v-if="weightFeatureAvailable">
|
||||
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
|
||||
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
|
||||
|
@ -323,7 +303,6 @@ export default {
|
|||
{{ list.totalWeight }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- EE end -->
|
||||
</span>
|
||||
</div>
|
||||
<gl-button-group
|
||||
|
@ -332,11 +311,13 @@ export default {
|
|||
>
|
||||
<gl-button
|
||||
v-if="isNewIssueShown"
|
||||
v-show="!list.collapsed"
|
||||
ref="newIssueBtn"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="$options.i18n.newIssue"
|
||||
:title="$options.i18n.newIssue"
|
||||
:class="{
|
||||
'gl-display-none': !list.isExpanded,
|
||||
}"
|
||||
:aria-label="__('New issue')"
|
||||
:title="__('New issue')"
|
||||
class="issue-count-badge-add-button no-drag"
|
||||
icon="plus"
|
||||
@click="showNewIssueForm"
|
||||
|
@ -346,13 +327,13 @@ export default {
|
|||
v-if="isSettingsShown"
|
||||
ref="settingsBtn"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="$options.i18n.listSettings"
|
||||
:aria-label="__('List settings')"
|
||||
class="no-drag js-board-settings-button"
|
||||
:title="$options.i18n.listSettings"
|
||||
:title="__('List settings')"
|
||||
icon="settings"
|
||||
@click="openSidebarSettings"
|
||||
/>
|
||||
<gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
|
||||
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
|
||||
</gl-button-group>
|
||||
</h3>
|
||||
</header>
|
|
@ -1,239 +0,0 @@
|
|||
<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>
|
|
@ -1,22 +1,24 @@
|
|||
<script>
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { getMilestone } from 'ee_else_ce/boards/boards_util';
|
||||
import ListIssue from 'ee_else_ce/boards/models/issue';
|
||||
import eventHub from '../eventhub';
|
||||
import ProjectSelect from './project_select_deprecated.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import ProjectSelect from './project_select.vue';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
|
||||
// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
name: 'BoardNewIssue',
|
||||
i18n: {
|
||||
submit: __('Submit issue'),
|
||||
cancel: __('Cancel'),
|
||||
},
|
||||
components: {
|
||||
ProjectSelect,
|
||||
GlButton,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
inject: ['groupId'],
|
||||
inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
|
@ -26,69 +28,58 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
title: '',
|
||||
error: false,
|
||||
selectedProject: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['selectedProject']),
|
||||
disabled() {
|
||||
if (this.groupId) {
|
||||
return this.title === '' || !this.selectedProject.name;
|
||||
}
|
||||
return this.title === '';
|
||||
},
|
||||
inputFieldId() {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
return `${this.list.id}-title`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus();
|
||||
eventHub.$on('setSelectedProject', this.setSelectedProject);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['addListNewIssue']),
|
||||
submit(e) {
|
||||
e.preventDefault();
|
||||
if (this.title.trim() === '') return Promise.resolve();
|
||||
|
||||
this.error = false;
|
||||
|
||||
const labels = this.list.label ? [this.list.label] : [];
|
||||
const assignees = this.list.assignee ? [this.list.assignee] : [];
|
||||
const milestone = getMilestone(this.list);
|
||||
|
||||
const { weightFeatureAvailable } = boardsStore;
|
||||
const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
|
||||
const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
|
||||
|
||||
const issue = new ListIssue({
|
||||
title: this.title,
|
||||
labels,
|
||||
subscribed: true,
|
||||
assignees,
|
||||
milestone,
|
||||
project_id: this.selectedProject.id,
|
||||
weight,
|
||||
});
|
||||
const { title } = this;
|
||||
|
||||
eventHub.$emit(`scroll-board-list-${this.list.id}`);
|
||||
this.cancel();
|
||||
|
||||
return this.list
|
||||
.newIssue(issue)
|
||||
.then(() => {
|
||||
boardsStore.setIssueDetail(issue);
|
||||
boardsStore.setListDetail(this.list);
|
||||
})
|
||||
.catch(() => {
|
||||
this.list.removeIssue(issue);
|
||||
|
||||
// Show error message
|
||||
this.error = true;
|
||||
});
|
||||
return this.addListNewIssue({
|
||||
issueInput: {
|
||||
title,
|
||||
labelIds: labels?.map((l) => l.id),
|
||||
assigneeIds: assignees?.map((a) => a?.id),
|
||||
milestoneId: milestone?.id,
|
||||
projectPath: this.selectedProject.fullPath,
|
||||
weight: weight >= 0 ? weight : null,
|
||||
},
|
||||
list: this.list,
|
||||
}).then(() => {
|
||||
this.reset();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
reset() {
|
||||
this.title = '';
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
setSelectedProject(selectedProject) {
|
||||
this.selectedProject = selectedProject;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -96,13 +87,10 @@ export default {
|
|||
<template>
|
||||
<div class="board-new-issue-form">
|
||||
<div class="board-card position-relative p-3 rounded">
|
||||
<form @submit="submit($event)">
|
||||
<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>
|
||||
<form ref="submitForm" @submit="submit">
|
||||
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
|
||||
<input
|
||||
:id="list.id + '-title'"
|
||||
:id="inputFieldId"
|
||||
ref="input"
|
||||
v-model="title"
|
||||
class="form-control"
|
||||
|
@ -119,16 +107,18 @@ export default {
|
|||
variant="success"
|
||||
category="primary"
|
||||
type="submit"
|
||||
>{{ __('Submit issue') }}</gl-button
|
||||
>
|
||||
{{ $options.i18n.submit }}
|
||||
</gl-button>
|
||||
<gl-button
|
||||
ref="cancelButton"
|
||||
class="float-right"
|
||||
type="button"
|
||||
variant="default"
|
||||
@click="cancel"
|
||||
>{{ __('Cancel') }}</gl-button
|
||||
@click="reset"
|
||||
>
|
||||
{{ $options.i18n.cancel }}
|
||||
</gl-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,24 +1,22 @@
|
|||
<script>
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { getMilestone } from 'ee_else_ce/boards/boards_util';
|
||||
import ListIssue from 'ee_else_ce/boards/models/issue';
|
||||
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 { __ } from '~/locale';
|
||||
|
||||
// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
|
||||
|
||||
export default {
|
||||
name: 'BoardNewIssue',
|
||||
i18n: {
|
||||
submit: __('Submit issue'),
|
||||
cancel: __('Cancel'),
|
||||
},
|
||||
components: {
|
||||
ProjectSelect,
|
||||
GlButton,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
|
||||
inject: ['groupId'],
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
|
@ -28,57 +26,69 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
title: '',
|
||||
error: false,
|
||||
selectedProject: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['selectedProject']),
|
||||
disabled() {
|
||||
if (this.groupId) {
|
||||
return this.title === '' || !this.selectedProject.name;
|
||||
}
|
||||
return this.title === '';
|
||||
},
|
||||
inputFieldId() {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
return `${this.list.id}-title`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus();
|
||||
eventHub.$on('setSelectedProject', this.setSelectedProject);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['addListNewIssue']),
|
||||
submit(e) {
|
||||
e.preventDefault();
|
||||
if (this.title.trim() === '') return Promise.resolve();
|
||||
|
||||
this.error = false;
|
||||
|
||||
const labels = this.list.label ? [this.list.label] : [];
|
||||
const assignees = this.list.assignee ? [this.list.assignee] : [];
|
||||
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}`);
|
||||
this.cancel();
|
||||
|
||||
return this.addListNewIssue({
|
||||
issueInput: {
|
||||
title,
|
||||
labelIds: labels?.map((l) => l.id),
|
||||
assigneeIds: assignees?.map((a) => a?.id),
|
||||
milestoneId: milestone?.id,
|
||||
projectPath: this.selectedProject.fullPath,
|
||||
weight: weight >= 0 ? weight : null,
|
||||
},
|
||||
list: this.list,
|
||||
}).then(() => {
|
||||
this.reset();
|
||||
});
|
||||
return this.list
|
||||
.newIssue(issue)
|
||||
.then(() => {
|
||||
boardsStore.setIssueDetail(issue);
|
||||
boardsStore.setListDetail(this.list);
|
||||
})
|
||||
.catch(() => {
|
||||
this.list.removeIssue(issue);
|
||||
|
||||
// Show error message
|
||||
this.error = true;
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
cancel() {
|
||||
this.title = '';
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
setSelectedProject(selectedProject) {
|
||||
this.selectedProject = selectedProject;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -86,10 +96,13 @@ export default {
|
|||
<template>
|
||||
<div class="board-new-issue-form">
|
||||
<div class="board-card position-relative p-3 rounded">
|
||||
<form ref="submitForm" @submit="submit">
|
||||
<label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label>
|
||||
<form @submit="submit($event)">
|
||||
<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
|
||||
:id="inputFieldId"
|
||||
:id="list.id + '-title'"
|
||||
ref="input"
|
||||
v-model="title"
|
||||
class="form-control"
|
||||
|
@ -106,18 +119,16 @@ export default {
|
|||
variant="success"
|
||||
category="primary"
|
||||
type="submit"
|
||||
>{{ __('Submit issue') }}</gl-button
|
||||
>
|
||||
{{ $options.i18n.submit }}
|
||||
</gl-button>
|
||||
<gl-button
|
||||
ref="cancelButton"
|
||||
class="float-right"
|
||||
type="button"
|
||||
variant="default"
|
||||
@click="reset"
|
||||
@click="cancel"
|
||||
>{{ __('Cancel') }}</gl-button
|
||||
>
|
||||
{{ $options.i18n.cancel }}
|
||||
</gl-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
|
@ -68,8 +68,10 @@ export default () => {
|
|||
issueBoardsApp.$destroy(true);
|
||||
}
|
||||
|
||||
boardsStore.create();
|
||||
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
|
||||
if (!gon?.features?.graphqlBoardLists) {
|
||||
boardsStore.create();
|
||||
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
|
||||
}
|
||||
|
||||
issueBoardsApp = new Vue({
|
||||
el: $boardApp,
|
||||
|
@ -127,8 +129,10 @@ export default () => {
|
|||
milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
|
||||
iterationId: parseInt($boardApp.dataset.boardIterationId, 10),
|
||||
iterationTitle: $boardApp.dataset.boardIterationTitle || '',
|
||||
assigneeId: $boardApp.dataset.boardAssigneeId,
|
||||
assigneeUsername: $boardApp.dataset.boardAssigneeUsername,
|
||||
labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels || []) : [],
|
||||
labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels) : [],
|
||||
labelIds: $boardApp.dataset.labelIds ? JSON.parse($boardApp.dataset.labelIds) : [],
|
||||
weight: $boardApp.dataset.boardWeight
|
||||
? parseInt($boardApp.dataset.boardWeight, 10)
|
||||
: null,
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
fullBoardId,
|
||||
formatListsPageInfo,
|
||||
formatIssue,
|
||||
formatIssueInput,
|
||||
updateListPosition,
|
||||
} from '../boards_util';
|
||||
import createFlash from '~/flash';
|
||||
|
@ -362,7 +363,10 @@ export default {
|
|||
},
|
||||
|
||||
createNewIssue: ({ commit, state }, issueInput) => {
|
||||
const input = issueInput;
|
||||
const { boardConfig } = state;
|
||||
|
||||
const input = formatIssueInput(issueInput, boardConfig);
|
||||
|
||||
const { boardType, fullPath } = state;
|
||||
if (boardType === BoardType.project) {
|
||||
input.projectPath = fullPath;
|
||||
|
|
|
@ -32,10 +32,6 @@ module SpammableActions
|
|||
elsif render_recaptcha?
|
||||
ensure_spam_config_loaded!
|
||||
|
||||
if params[:recaptcha_verification]
|
||||
flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
render :verify
|
||||
|
@ -56,9 +52,9 @@ module SpammableActions
|
|||
def spammable_params
|
||||
default_params = { request: request }
|
||||
|
||||
recaptcha_check = params[:recaptcha_verification] &&
|
||||
recaptcha_check = recaptcha_response &&
|
||||
ensure_spam_config_loaded! &&
|
||||
verify_recaptcha
|
||||
verify_recaptcha(response: recaptcha_response)
|
||||
|
||||
return default_params unless recaptcha_check
|
||||
|
||||
|
@ -66,6 +62,23 @@ module SpammableActions
|
|||
spam_log_id: params[:spam_log_id] }.merge(default_params)
|
||||
end
|
||||
|
||||
def recaptcha_response
|
||||
# NOTE: This field name comes from `Recaptcha::ClientHelper#recaptcha_tags` in the recaptcha
|
||||
# gem, which is called from the HAML `_recaptcha_form.html.haml` form.
|
||||
#
|
||||
# It is used in the `Recaptcha::Verify#verify_recaptcha` if the `response` option is not
|
||||
# passed explicitly.
|
||||
#
|
||||
# Instead of relying on this behavior, we are extracting and passing it explicitly. This will
|
||||
# make it consistent with the newer, modern reCAPTCHA verification process as it will be
|
||||
# implemented via the GraphQL API and in Vue components via the native reCAPTCHA Javascript API,
|
||||
# which requires that the recaptcha response param be obtained and passed explicitly.
|
||||
#
|
||||
# After this newer GraphQL/JS API process is fully supported by the backend, we can remove this
|
||||
# (and other) HAML-specific support.
|
||||
params['g-recaptcha-response']
|
||||
end
|
||||
|
||||
def spammable
|
||||
raise NotImplementedError, "#{self.class} does not implement #{__method__}"
|
||||
end
|
||||
|
|
|
@ -10,7 +10,11 @@ class GroupMemberPolicy < BasePolicy
|
|||
with_score 0
|
||||
condition(:is_target_user) { @user && @subject.user_id == @user.id }
|
||||
|
||||
rule { anonymous }.prevent_all
|
||||
rule { anonymous }.policy do
|
||||
prevent :update_group_member
|
||||
prevent :destroy_group_member
|
||||
end
|
||||
|
||||
rule { last_owner }.policy do
|
||||
prevent :update_group_member
|
||||
prevent :destroy_group_member
|
||||
|
|
|
@ -66,7 +66,7 @@ class GroupPolicy < BasePolicy
|
|||
with_scope :subject
|
||||
condition(:has_project_with_service_desk_enabled) { @subject.has_project_with_service_desk_enabled? }
|
||||
|
||||
rule { design_management_enabled }.policy do
|
||||
rule { can?(:read_group) & design_management_enabled }.policy do
|
||||
enable :read_design_activity
|
||||
end
|
||||
|
||||
|
|
|
@ -25,3 +25,5 @@ module Labels
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Labels::CreateService.prepend_if_ee('EE::Labels::CreateService')
|
||||
|
|
|
@ -3,29 +3,41 @@
|
|||
.gl-mt-3
|
||||
.row
|
||||
.col-sm
|
||||
.bg-light.light-well
|
||||
%h4= _('CPU')
|
||||
.bg-light.info-well.p-3
|
||||
%h4.page-title.d-flex
|
||||
.gl-display-flex.gl-align-items-center.gl-justify-content-center
|
||||
= sprite_icon('pod', size: 18, css_class: 'pod-icon gl-mr-3')
|
||||
= _('CPU')
|
||||
.data
|
||||
- if @cpus
|
||||
%h2= _('%{cores} cores') % { cores: @cpus.length }
|
||||
- else
|
||||
= sprite_icon('warning-solid', css_class: 'text-warning')
|
||||
= _('Unable to collect CPU info')
|
||||
.bg-light.light-well.gl-mt-3
|
||||
%h4= _('Memory Usage')
|
||||
.bg-light.info-well.p-3.gl-mt-3
|
||||
%h4.page-title.d-flex
|
||||
.gl-display-flex.gl-align-items-center.gl-justify-content-center
|
||||
= sprite_icon('status-health', size: 18, css_class: 'pod-icon gl-mr-3')
|
||||
= _('Memory Usage')
|
||||
.data
|
||||
- if @memory
|
||||
%h2 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
|
||||
- else
|
||||
= sprite_icon('warning-solid', css_class: 'text-warning')
|
||||
= _('Unable to collect memory info')
|
||||
.bg-light.light-well.gl-mt-3
|
||||
%h4= _('Uptime')
|
||||
.bg-light.info-well.p-3.gl-mt-3
|
||||
%h4.page-title.d-flex
|
||||
.gl-display-flex.gl-align-items-center.gl-justify-content-center
|
||||
= sprite_icon('clock', size: 18, css_class: 'pod-icon gl-mr-3')
|
||||
= _('Uptime')
|
||||
.data
|
||||
%h2= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
|
||||
.col-sm
|
||||
.bg-light.light-well
|
||||
%h4= _('Disk Usage')
|
||||
.bg-light.info-well.p-3
|
||||
%h4.page-title.d-flex
|
||||
.gl-display-flex.gl-align-items-center.gl-justify-content-center
|
||||
= sprite_icon('disk', size: 18, css_class: 'pod-icon gl-mr-3')
|
||||
= _('Disk Usage')
|
||||
.data
|
||||
%ul
|
||||
- @disks.each do |disk|
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
= render 'layouts/recaptcha_verification', spammable: @snippet
|
||||
|
|
@ -9,8 +9,11 @@
|
|||
- params[resource_name].each do |field, value|
|
||||
= hidden_field(resource_name, field, value: value)
|
||||
= hidden_field_tag(:spam_log_id, spammable.spam_log.id)
|
||||
= hidden_field_tag(:recaptcha_verification, true)
|
||||
-# The reCAPTCHA response value will be returned in the 'g-recaptcha-response' field
|
||||
= recaptcha_tags script: script, callback: 'recaptchaDialogCallback' unless Rails.env.test?
|
||||
-# Fake the 'g-recaptcha-response' field in the test environment, so that the feature spec
|
||||
-# can get to the (mocked) SpamVerdictService check.
|
||||
= hidden_field_tag('g-recaptcha-response', 'abc123') if Rails.env.test?
|
||||
|
||||
-# Yields a block with given extra params.
|
||||
= yield
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
= render 'layouts/recaptcha_verification', spammable: @snippet
|
||||
|
5
changelogs/unreleased/add-icons.yml
Normal file
5
changelogs/unreleased/add-icons.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add Icons to headings in system info on admin panel
|
||||
merge_request: 46618
|
||||
author: Yogi (@yo)
|
||||
type: other
|
5
changelogs/unreleased/ajk-group-member-policy-fix.yml
Normal file
5
changelogs/unreleased/ajk-group-member-policy-fix.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow more actions on group members
|
||||
merge_request: 50445
|
||||
author:
|
||||
type: fixed
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
name: graphql_logging
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27885
|
||||
rollout_issue_url:
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/35579
|
||||
milestone: '12.0'
|
||||
type: development
|
||||
group:
|
||||
group: group::project management
|
||||
default_enabled: true
|
||||
|
|
198
spec/controllers/concerns/spammable_actions_spec.rb
Normal file
198
spec/controllers/concerns/spammable_actions_spec.rb
Normal file
|
@ -0,0 +1,198 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe SpammableActions do
|
||||
controller(ActionController::Base) do
|
||||
include SpammableActions
|
||||
|
||||
# #create is used to test spammable_params
|
||||
# for testing purposes
|
||||
def create
|
||||
spam_params = spammable_params
|
||||
|
||||
# replace the actual request with a string in the JSON response, all we care is that it got set
|
||||
spam_params[:request] = 'this is the request' if spam_params[:request]
|
||||
|
||||
# just return the params in the response so they can be verified in this fake controller spec.
|
||||
# Normally, they are processed further by the controller action
|
||||
render json: spam_params.to_json, status: :ok
|
||||
end
|
||||
|
||||
# #update is used to test recaptcha_check_with_fallback
|
||||
# for testing purposes
|
||||
def update
|
||||
should_redirect = params[:should_redirect] == 'true'
|
||||
|
||||
recaptcha_check_with_fallback(should_redirect) { render json: :ok }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def spammable_path
|
||||
'/fake_spammable_path'
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
# Ordinarily we would not stub a method on the class under test, but :ensure_spam_config_loaded!
|
||||
# returns false in the test environment, and is also strong_memoized, so we need to stub it
|
||||
allow(controller).to receive(:ensure_spam_config_loaded!) { true }
|
||||
end
|
||||
|
||||
describe '#spammable_params' do
|
||||
subject { post :create, format: :json, params: params }
|
||||
|
||||
shared_examples 'expects request param only' do
|
||||
it do
|
||||
subject
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(json_response).to eq({ 'request' => 'this is the request' })
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'expects all spammable params' do
|
||||
it do
|
||||
subject
|
||||
|
||||
expect(response).to be_successful
|
||||
expect(json_response['request']).to eq('this is the request')
|
||||
expect(json_response['recaptcha_verified']).to eq(true)
|
||||
expect(json_response['spam_log_id']).to eq('1')
|
||||
end
|
||||
end
|
||||
|
||||
let(:recaptcha_response) { nil }
|
||||
let(:spam_log_id) { nil }
|
||||
|
||||
context 'when recaptcha response is not present' do
|
||||
let(:params) do
|
||||
{
|
||||
spam_log_id: spam_log_id
|
||||
}
|
||||
end
|
||||
|
||||
it_behaves_like 'expects request param only'
|
||||
end
|
||||
|
||||
context 'when recaptcha response is present' do
|
||||
let(:recaptcha_response) { 'abd123' }
|
||||
let(:params) do
|
||||
{
|
||||
'g-recaptcha-response': recaptcha_response,
|
||||
spam_log_id: spam_log_id
|
||||
}
|
||||
end
|
||||
|
||||
context 'when verify_recaptcha returns falsey' do
|
||||
before do
|
||||
expect(controller).to receive(:verify_recaptcha).with(
|
||||
{
|
||||
response: recaptcha_response
|
||||
}) { false }
|
||||
end
|
||||
|
||||
it_behaves_like 'expects request param only'
|
||||
end
|
||||
|
||||
context 'when verify_recaptcha returns truthy' do
|
||||
let(:spam_log_id) { 1 }
|
||||
|
||||
before do
|
||||
expect(controller).to receive(:verify_recaptcha).with(
|
||||
{
|
||||
response: recaptcha_response
|
||||
}) { true }
|
||||
end
|
||||
|
||||
it_behaves_like 'expects all spammable params'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#recaptcha_check_with_fallback' do
|
||||
shared_examples 'yields to block' do
|
||||
it do
|
||||
subject
|
||||
|
||||
expect(json_response).to eq({ json: 'ok' })
|
||||
end
|
||||
end
|
||||
|
||||
let(:format) { :html }
|
||||
|
||||
subject { post :update, format: format, params: params }
|
||||
|
||||
let(:spammable) { double(:spammable) }
|
||||
let(:should_redirect) { nil }
|
||||
let(:params) do
|
||||
{
|
||||
should_redirect: should_redirect
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
routes.draw { get 'update' => 'anonymous#update' }
|
||||
allow(controller).to receive(:spammable) { spammable }
|
||||
end
|
||||
|
||||
context 'when should_redirect is true and spammable is valid' do
|
||||
let(:should_redirect) { true }
|
||||
|
||||
before do
|
||||
allow(spammable).to receive(:valid?) { true }
|
||||
end
|
||||
|
||||
it 'redirects to spammable_path' do
|
||||
expect(subject).to redirect_to('/fake_spammable_path')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when should_redirect is false or spammable is not valid' do
|
||||
before do
|
||||
allow(spammable).to receive(:valid?) { false }
|
||||
end
|
||||
|
||||
# NOTE: Not adding coverage of details of render_recaptcha?, the plan is to refactor it out
|
||||
# of this module anyway as part of adding support for the GraphQL reCAPTCHA flow.
|
||||
|
||||
context 'when render_recaptcha? is true' do
|
||||
before do
|
||||
expect(controller).to receive(:render_recaptcha?) { true }
|
||||
end
|
||||
|
||||
context 'when format is :html' do
|
||||
it 'renders :verify' do
|
||||
expect(controller).to receive(:render).with(:verify)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
context 'when format is :json' do
|
||||
let(:format) { :json }
|
||||
let(:recaptcha_html) { '<recaptcha-html/>' }
|
||||
|
||||
it 'renders json with recaptcha_html' do
|
||||
expect(controller).to receive(:render_to_string).with(
|
||||
{
|
||||
partial: 'shared/recaptcha_form',
|
||||
formats: :html,
|
||||
locals: {
|
||||
spammable: spammable,
|
||||
script: false,
|
||||
has_submit: false
|
||||
}
|
||||
}
|
||||
) { recaptcha_html }
|
||||
|
||||
subject
|
||||
|
||||
expect(json_response).to eq({ 'recaptcha_html' => recaptcha_html })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1019,7 +1019,7 @@ RSpec.describe Projects::IssuesController do
|
|||
def update_verified_issue
|
||||
update_issue(
|
||||
issue_params: { title: spammy_title },
|
||||
additional_params: { spam_log_id: spam_logs.last.id, recaptcha_verification: true })
|
||||
additional_params: { spam_log_id: spam_logs.last.id, 'g-recaptcha-response': true })
|
||||
end
|
||||
|
||||
it 'returns 200 status' do
|
||||
|
@ -1037,7 +1037,7 @@ RSpec.describe Projects::IssuesController do
|
|||
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
|
||||
spam_log = create(:spam_log)
|
||||
|
||||
expect { update_issue(issue_params: { spam_log_id: spam_log.id, recaptcha_verification: true }) }
|
||||
expect { update_issue(issue_params: { spam_log_id: spam_log.id, 'g-recaptcha-response': true }) }
|
||||
.not_to change { SpamLog.last.recaptcha_verified }
|
||||
end
|
||||
end
|
||||
|
@ -1314,7 +1314,7 @@ RSpec.describe Projects::IssuesController do
|
|||
let!(:last_spam_log) { spam_logs.last }
|
||||
|
||||
def post_verified_issue
|
||||
post_new_issue({}, { spam_log_id: last_spam_log.id, recaptcha_verification: true } )
|
||||
post_new_issue({}, { spam_log_id: last_spam_log.id, 'g-recaptcha-response': true } )
|
||||
end
|
||||
|
||||
before do
|
||||
|
@ -1332,7 +1332,7 @@ RSpec.describe Projects::IssuesController do
|
|||
it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do
|
||||
spam_log = create(:spam_log)
|
||||
|
||||
expect { post_new_issue({}, { spam_log_id: spam_log.id, recaptcha_verification: true } ) }
|
||||
expect { post_new_issue({}, { spam_log_id: spam_log.id, 'g-recaptcha-response': true } ) }
|
||||
.not_to change { last_spam_log.recaptcha_verified }
|
||||
end
|
||||
end
|
||||
|
|
275
spec/frontend/boards/board_list_deprecated_spec.js
Normal file
275
spec/frontend/boards/board_list_deprecated_spec.js
Normal file
|
@ -0,0 +1,275 @@
|
|||
/* 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';
|
|||
import Vue from 'vue';
|
||||
import Sortable from 'sortablejs';
|
||||
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/list';
|
||||
|
|
|
@ -1,268 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,29 +1,51 @@
|
|||
/* 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 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.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';
|
||||
import BoardCard from '~/boards/components/board_card.vue';
|
||||
import { mockList, mockIssuesByListId, issues, mockIssues } from './mock_data';
|
||||
import defaultState from '~/boards/stores/state';
|
||||
|
||||
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
|
||||
const el = document.createElement('div');
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
document.body.appendChild(el);
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onAny().reply(boardsMockInterceptor);
|
||||
boardsStore.create();
|
||||
const actions = {
|
||||
fetchIssuesForList: jest.fn(),
|
||||
};
|
||||
|
||||
const BoardListComp = Vue.extend(BoardList);
|
||||
const list = new List({ ...listObj, ...listProps });
|
||||
const issue = new ListIssue({
|
||||
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,
|
||||
|
@ -31,244 +53,214 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP
|
|||
labels: [],
|
||||
assignees: [],
|
||||
...listIssueProps,
|
||||
});
|
||||
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
|
||||
list.issuesSize = 1;
|
||||
};
|
||||
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesCount')) {
|
||||
list.issuesCount = 1;
|
||||
}
|
||||
list.issues.push(issue);
|
||||
|
||||
const component = new BoardListComp({
|
||||
el,
|
||||
store,
|
||||
const component = mount(BoardList, {
|
||||
localVue,
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list,
|
||||
issues: list.issues,
|
||||
issues: [issue],
|
||||
canAdminList: true,
|
||||
...componentProps,
|
||||
},
|
||||
store,
|
||||
provide: {
|
||||
groupId: null,
|
||||
rootPath: '/',
|
||||
weightFeatureAvailable: false,
|
||||
boardWeight: null,
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
done();
|
||||
});
|
||||
|
||||
return { component, mock };
|
||||
return component;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
let wrapper;
|
||||
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
|
||||
useFakeRequestAnimationFrame();
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
component.list.loading = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
|
||||
wrapper = createComponent({
|
||||
state: { listsFlags: { 'gid://gitlab/List/1': { isLoading: true } } },
|
||||
});
|
||||
|
||||
expect(findByTestId('board_list_loading').exists()).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
component.toggleForm();
|
||||
it('shows new issue form', async () => {
|
||||
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', () => {
|
||||
eventHub.$emit(`toggle-issue-form-${component.list.id}`);
|
||||
it('shows new issue form after eventhub event', async () => {
|
||||
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', () => {
|
||||
component.list.type = 'closed';
|
||||
component.toggleForm();
|
||||
wrapper.setProps({ list: { type: 'closed' } });
|
||||
wrapper.vm.toggleForm();
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
|
||||
});
|
||||
expect(wrapper.find('.board-new-issue-form').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows count list item', () => {
|
||||
component.showCount = true;
|
||||
it('shows count list item', async () => {
|
||||
wrapper.vm.showCount = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.board-list-count').exists()).toBe(true);
|
||||
|
||||
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
|
||||
'Showing all issues',
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('.board-list-count').text()).toBe('Showing all issues');
|
||||
});
|
||||
|
||||
it('sets data attribute with invalid id', () => {
|
||||
component.showCount = true;
|
||||
it('sets data attribute with invalid id', async () => {
|
||||
wrapper.vm.showCount = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
|
||||
'-1',
|
||||
);
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
|
||||
});
|
||||
|
||||
it('shows how many more issues to load', () => {
|
||||
component.showCount = true;
|
||||
component.list.issuesSize = 20;
|
||||
it('shows how many more issues to load', async () => {
|
||||
wrapper.vm.showCount = true;
|
||||
wrapper.setProps({ list: { issuesCount: 20 } });
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
|
||||
'Showing 1 of 20 issues',
|
||||
);
|
||||
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', () => {
|
||||
jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
|
||||
generateIssues(component);
|
||||
component.$refs.list.dispatchEvent(new Event('scroll'));
|
||||
wrapper.vm.listRef.dispatchEvent(new Event('scroll'));
|
||||
|
||||
return waitForPromises().then(() => {
|
||||
expect(component.list.nextPage).toHaveBeenCalled();
|
||||
});
|
||||
expect(actions.fetchIssuesForList).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);
|
||||
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', () => {
|
||||
component.showCount = true;
|
||||
component.list.loadingMore = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
|
||||
it('shows loading more spinner', async () => {
|
||||
wrapper = createComponent({
|
||||
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
wrapper.vm.showCount = true;
|
||||
|
||||
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);
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.board-list-count .gl-spinner').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('max issue count warning', () => {
|
||||
beforeEach((done) => {
|
||||
({ mock, component } = createComponent({
|
||||
done,
|
||||
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
component.$destroy();
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent({
|
||||
listProps: { issuesCount: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
describe('when issue count exceeds max issue count', () => {
|
||||
it('sets background to bg-danger-100', () => {
|
||||
component.list.issuesSize = 4;
|
||||
component.list.maxIssueCount = 3;
|
||||
it('sets background to bg-danger-100', async () => {
|
||||
wrapper.setProps({ list: { issuesCount: 4, maxIssueCount: 3 } });
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
|
||||
});
|
||||
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', () => {
|
||||
component.list.issuesSize = 2;
|
||||
component.list.maxIssueCount = 3;
|
||||
wrapper.setProps({ list: { issuesCount: 2, maxIssueCount: 3 } });
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
|
||||
});
|
||||
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', () => {
|
||||
component.list.maxIssueCount = 0;
|
||||
wrapper.setProps({ list: { maxIssueCount: 0 } });
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,7 @@ import Vue from 'vue';
|
|||
import { mount } from '@vue/test-utils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
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 '~/boards/models/list';
|
|
@ -1,40 +1,65 @@
|
|||
import Vue from 'vue';
|
||||
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 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 { createStore } from '~/boards/stores';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
describe('Board Column Component', () => {
|
||||
let wrapper;
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
beforeEach(() => {
|
||||
window.gon = {};
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
|
||||
});
|
||||
|
||||
const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const createComponent = ({
|
||||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
withLocalStorage = true,
|
||||
} = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...listObj,
|
||||
listType,
|
||||
list_type: listType,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.assignee = {};
|
||||
listMock.user = {};
|
||||
}
|
||||
|
||||
store = createStore();
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
|
||||
wrapper = shallowMount(BoardColumn, {
|
||||
store,
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
wrapper = shallowMount(Board, {
|
||||
propsData: {
|
||||
boardId,
|
||||
disabled: false,
|
||||
list: listMock,
|
||||
list,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
|
@ -57,7 +82,7 @@ describe('Board Column Component', () => {
|
|||
it('has class is-collapsed when list is collapsed', () => {
|
||||
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', () => {
|
|
@ -1,65 +1,40 @@
|
|||
import Vue from 'vue';
|
||||
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 Board from '~/boards/components/board_column.vue';
|
||||
import List from '~/boards/models/list';
|
||||
import BoardColumn from '~/boards/components/board_column.vue';
|
||||
import { ListType } from '~/boards/constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { createStore } from '~/boards/stores';
|
||||
|
||||
describe('Board Column Component', () => {
|
||||
let wrapper;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
window.gon = {};
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
|
||||
});
|
||||
let store;
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
localStorage.clear();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const createComponent = ({
|
||||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
withLocalStorage = true,
|
||||
} = {}) => {
|
||||
const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...listObj,
|
||||
list_type: listType,
|
||||
listType,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.user = {};
|
||||
listMock.assignee = {};
|
||||
}
|
||||
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
store = createStore();
|
||||
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
wrapper = shallowMount(Board, {
|
||||
wrapper = shallowMount(BoardColumn, {
|
||||
store,
|
||||
propsData: {
|
||||
boardId,
|
||||
disabled: false,
|
||||
list,
|
||||
list: listMock,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
|
@ -82,7 +57,7 @@ describe('Board Column Component', () => {
|
|||
it('has class is-collapsed when list is collapsed', () => {
|
||||
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', () => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { GlAlert } from '@gitlab/ui';
|
|||
import Draggable from 'vuedraggable';
|
||||
import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue';
|
||||
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 BoardContent from '~/boards/components/board_content.vue';
|
||||
|
||||
|
@ -17,6 +17,7 @@ const actions = {
|
|||
|
||||
describe('BoardContent', () => {
|
||||
let wrapper;
|
||||
window.gon = {};
|
||||
|
||||
const defaultState = {
|
||||
isShowingEpicsSwimlanes: false,
|
||||
|
@ -56,10 +57,12 @@ describe('BoardContent', () => {
|
|||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders a BoardColumn component per list', () => {
|
||||
it('renders a BoardColumnDeprecated component per list', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.findAll(BoardColumn)).toHaveLength(mockLists.length);
|
||||
expect(wrapper.findAllComponents(BoardColumnDeprecated)).toHaveLength(
|
||||
mockListsWithModel.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not display EpicsSwimlanes component', () => {
|
||||
|
@ -70,6 +73,13 @@ describe('BoardContent', () => {
|
|||
});
|
||||
|
||||
describe('graphqlBoardLists feature flag enabled', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ graphqlBoardListsEnabled: true });
|
||||
gon.features = {
|
||||
graphqlBoardLists: true,
|
||||
};
|
||||
});
|
||||
|
||||
describe('can admin list', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: true } });
|
||||
|
@ -85,7 +95,7 @@ describe('BoardContent', () => {
|
|||
createComponent({ graphqlBoardListsEnabled: true, props: { canAdminList: false } });
|
||||
});
|
||||
|
||||
it('renders draggable component', () => {
|
||||
it('does not render draggable component', () => {
|
||||
expect(wrapper.find(Draggable).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,23 +1,28 @@
|
|||
import Vuex from 'vuex';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
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';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
localVue.use(Vuex);
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
describe('Board List Header Component', () => {
|
||||
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(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
@ -26,54 +31,45 @@ describe('Board List Header Component', () => {
|
|||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
withLocalStorage = true,
|
||||
currentUserId = null,
|
||||
} = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...mockLabelList,
|
||||
listType,
|
||||
...listObj,
|
||||
list_type: listType,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.assignee = {};
|
||||
listMock.user = {};
|
||||
}
|
||||
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
store = new Vuex.Store({
|
||||
state: {},
|
||||
actions: { updateList: updateListSpy },
|
||||
getters: {},
|
||||
});
|
||||
|
||||
wrapper = shallowMount(BoardListHeader, {
|
||||
store,
|
||||
localVue,
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list: listMock,
|
||||
list,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
weightFeatureAvailable: false,
|
||||
currentUserId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isCollapsed = () => wrapper.vm.list.collapsed;
|
||||
const isExpanded = () => !isCollapsed;
|
||||
const isCollapsed = () => !wrapper.props().list.isExpanded;
|
||||
const isExpanded = () => wrapper.vm.list.isExpanded;
|
||||
|
||||
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
|
||||
const findTitle = () => wrapper.find('.board-title');
|
||||
const findCaret = () => wrapper.find('.board-title-caret');
|
||||
|
||||
describe('Add issue button', () => {
|
||||
|
@ -93,8 +89,6 @@ describe('Board List Header Component', () => {
|
|||
});
|
||||
|
||||
it('has a test for each list type', () => {
|
||||
createComponent();
|
||||
|
||||
Object.values(ListType).forEach((value) => {
|
||||
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
|
||||
});
|
||||
|
@ -108,80 +102,64 @@ describe('Board List Header Component', () => {
|
|||
});
|
||||
|
||||
describe('expanding / collapsing the column', () => {
|
||||
it('does not collapse when clicking the header', async () => {
|
||||
it('does not collapse when clicking the header', () => {
|
||||
createComponent();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
|
||||
wrapper.find('[data-testid="board-list-header"]').trigger('click');
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses expanded Column when clicking the collapse icon', async () => {
|
||||
it('collapses expanded Column when clicking the collapse icon', () => {
|
||||
createComponent();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
|
||||
expect(isExpanded()).toBe(true);
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(isCollapsed()).toBe(true);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
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 });
|
||||
|
||||
expect(isCollapsed()).toBe(true);
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("when logged in it calls list update and doesn't set localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
window.gon.current_user_id = 1;
|
||||
|
||||
createComponent({ withLocalStorage: false });
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("when logged in it calls list update and doesn't set localStorage", async () => {
|
||||
createComponent({ withLocalStorage: false, currentUserId: 1 });
|
||||
it("when logged out it doesn't call list update and sets localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
|
||||
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();
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(updateListSpy).not.toHaveBeenCalled();
|
||||
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');
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,28 +1,23 @@
|
|||
import Vue from 'vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import AxiosMockAdapter from 'axios-mock-adapter';
|
||||
import Vuex from 'vuex';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import { listObj } from 'jest/boards/mock_data';
|
||||
import { mockLabelList } from 'jest/boards/mock_data';
|
||||
import BoardListHeader from '~/boards/components/board_list_header.vue';
|
||||
import List from '~/boards/models/list';
|
||||
import { ListType } from '~/boards/constants';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('Board List Header Component', () => {
|
||||
let wrapper;
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
window.gon = {};
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
|
||||
});
|
||||
const updateListSpy = jest.fn();
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
@ -31,45 +26,54 @@ describe('Board List Header Component', () => {
|
|||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
withLocalStorage = true,
|
||||
currentUserId = null,
|
||||
} = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...listObj,
|
||||
list_type: listType,
|
||||
...mockLabelList,
|
||||
listType,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.user = {};
|
||||
listMock.assignee = {};
|
||||
}
|
||||
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
`boards.${boardId}.${listMock.listType}.${listMock.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
store = new Vuex.Store({
|
||||
state: {},
|
||||
actions: { updateList: updateListSpy },
|
||||
getters: {},
|
||||
});
|
||||
|
||||
wrapper = shallowMount(BoardListHeader, {
|
||||
store,
|
||||
localVue,
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list,
|
||||
list: listMock,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
weightFeatureAvailable: false,
|
||||
currentUserId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isCollapsed = () => !wrapper.props().list.isExpanded;
|
||||
const isExpanded = () => wrapper.vm.list.isExpanded;
|
||||
const isCollapsed = () => wrapper.vm.list.collapsed;
|
||||
const isExpanded = () => !isCollapsed;
|
||||
|
||||
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
|
||||
const findTitle = () => wrapper.find('.board-title');
|
||||
const findCaret = () => wrapper.find('.board-title-caret');
|
||||
|
||||
describe('Add issue button', () => {
|
||||
|
@ -89,6 +93,8 @@ describe('Board List Header Component', () => {
|
|||
});
|
||||
|
||||
it('has a test for each list type', () => {
|
||||
createComponent();
|
||||
|
||||
Object.values(ListType).forEach((value) => {
|
||||
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
|
||||
});
|
||||
|
@ -102,64 +108,80 @@ describe('Board List Header Component', () => {
|
|||
});
|
||||
|
||||
describe('expanding / collapsing the column', () => {
|
||||
it('does not collapse when clicking the header', () => {
|
||||
it('does not collapse when clicking the header', async () => {
|
||||
createComponent();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
|
||||
wrapper.find('[data-testid="board-list-header"]').trigger('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses expanded Column when clicking the collapse icon', () => {
|
||||
it('collapses expanded Column when clicking the collapse icon', async () => {
|
||||
createComponent();
|
||||
|
||||
expect(isExpanded()).toBe(true);
|
||||
expect(isCollapsed()).toBe(false);
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(true);
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
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 });
|
||||
|
||||
expect(isCollapsed()).toBe(true);
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("when logged in it calls list update and doesn't set localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
window.gon.current_user_id = 1;
|
||||
|
||||
createComponent({ withLocalStorage: false });
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it("when logged out it doesn't call list update and sets localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
it("when logged in it calls list update and doesn't set localStorage", async () => {
|
||||
createComponent({ withLocalStorage: false, currentUserId: 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();
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
|
||||
});
|
||||
expect(updateListSpy).not.toHaveBeenCalled();
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Vuex from 'vuex';
|
||||
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 { mockList, mockGroupProjects } from '../mock_data';
|
|
@ -285,7 +285,7 @@ export const setMockEndpoints = (opts = {}) => {
|
|||
export const mockList = {
|
||||
id: 'gid://gitlab/List/1',
|
||||
title: 'Backlog',
|
||||
position: null,
|
||||
position: -Infinity,
|
||||
listType: 'backlog',
|
||||
collapsed: false,
|
||||
label: null,
|
||||
|
|
|
@ -16,8 +16,14 @@ import * as types from '~/boards/stores/mutation_types';
|
|||
import { inactiveId } from '~/boards/constants';
|
||||
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
|
||||
import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutation.graphql';
|
||||
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
|
||||
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
|
||||
import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util';
|
||||
import {
|
||||
fullBoardId,
|
||||
formatListIssues,
|
||||
formatBoardLists,
|
||||
formatIssueInput,
|
||||
} from '~/boards/boards_util';
|
||||
import createFlash from '~/flash';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
@ -728,6 +734,27 @@ describe('createNewIssue', () => {
|
|||
const state = {
|
||||
boardType: 'group',
|
||||
fullPath: 'gitlab-org/gitlab',
|
||||
boardConfig: {
|
||||
labelIds: [],
|
||||
assigneeId: null,
|
||||
milestoneId: -1,
|
||||
},
|
||||
};
|
||||
|
||||
const stateWithBoardConfig = {
|
||||
boardConfig: {
|
||||
labels: [
|
||||
{
|
||||
id: 5,
|
||||
title: 'Test',
|
||||
color: '#ff0000',
|
||||
description: 'testing;',
|
||||
textColor: 'white',
|
||||
},
|
||||
],
|
||||
assigneeId: 2,
|
||||
milestoneId: 3,
|
||||
},
|
||||
};
|
||||
|
||||
it('should return issue from API on success', async () => {
|
||||
|
@ -744,11 +771,59 @@ describe('createNewIssue', () => {
|
|||
expect(result).toEqual(mockIssue);
|
||||
});
|
||||
|
||||
it('should add board scope to the issue being created', async () => {
|
||||
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
|
||||
data: {
|
||||
createIssue: {
|
||||
issue: mockIssue,
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.createNewIssue({ state: stateWithBoardConfig }, mockIssue);
|
||||
expect(gqlClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: issueCreateMutation,
|
||||
variables: {
|
||||
input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add board scope by merging attributes to the issue being created', async () => {
|
||||
const issue = {
|
||||
...mockIssue,
|
||||
assigneeIds: ['gid://gitlab/User/1'],
|
||||
labelIds: ['gid://gitlab/GroupLabel/4'],
|
||||
};
|
||||
|
||||
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
|
||||
data: {
|
||||
createIssue: {
|
||||
issue,
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const payload = formatIssueInput(issue, stateWithBoardConfig.boardConfig);
|
||||
|
||||
await actions.createNewIssue({ state: stateWithBoardConfig }, issue);
|
||||
expect(gqlClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: issueCreateMutation,
|
||||
variables: {
|
||||
input: formatIssueInput(issue, stateWithBoardConfig.boardConfig),
|
||||
},
|
||||
});
|
||||
expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
|
||||
expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']);
|
||||
});
|
||||
|
||||
it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', (done) => {
|
||||
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
|
||||
data: {
|
||||
createIssue: {
|
||||
issue: {},
|
||||
issue: mockIssue,
|
||||
errors: [{ foo: 'bar' }],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GroupMemberPolicy do
|
||||
include DesignManagementTestHelpers
|
||||
|
||||
let(:guest) { create(:user) }
|
||||
let(:owner) { create(:user) }
|
||||
let(:group) { create(:group, :private) }
|
||||
|
@ -28,22 +30,64 @@ RSpec.describe GroupMemberPolicy do
|
|||
permissions.each { |p| is_expected.not_to be_allowed(p) }
|
||||
end
|
||||
|
||||
context 'with guest user' do
|
||||
let(:current_user) { guest }
|
||||
context 'with anonymous user' do
|
||||
let(:group) { create(:group, :public) }
|
||||
let(:current_user) { nil }
|
||||
let(:membership) { guest.members.first }
|
||||
|
||||
it do
|
||||
expect_disallowed(:member_related_permissions)
|
||||
expect_disallowed(:read_design_activity, *member_related_permissions)
|
||||
expect_allowed(:read_group)
|
||||
end
|
||||
|
||||
context 'design management is enabled' do
|
||||
before do
|
||||
create(:project, :public, group: group) # Necessary to enable design management
|
||||
enable_design_management
|
||||
end
|
||||
|
||||
specify do
|
||||
expect_allowed(:read_design_activity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'for a private group' do
|
||||
let(:group) { create(:group, :private) }
|
||||
|
||||
specify do
|
||||
expect_disallowed(:read_group, :read_design_activity, *member_related_permissions)
|
||||
end
|
||||
end
|
||||
|
||||
context 'for an internal group' do
|
||||
let(:group) { create(:group, :internal) }
|
||||
|
||||
specify do
|
||||
expect_disallowed(:read_group, :read_design_activity, *member_related_permissions)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with guest user, for own membership' do
|
||||
let(:current_user) { guest }
|
||||
|
||||
specify { expect_disallowed(:update_group_member) }
|
||||
specify { expect_allowed(:read_group, :destroy_group_member) }
|
||||
end
|
||||
|
||||
context 'with guest user, for other membership' do
|
||||
let(:current_user) { guest }
|
||||
let(:membership) { owner.members.first }
|
||||
|
||||
specify { expect_disallowed(:destroy_group_member, :update_group_member) }
|
||||
specify { expect_allowed(:read_group) }
|
||||
end
|
||||
|
||||
context 'with one owner' do
|
||||
let(:current_user) { owner }
|
||||
|
||||
it do
|
||||
expect_disallowed(:destroy_group_member)
|
||||
expect_disallowed(:update_group_member)
|
||||
expect_allowed(:read_group)
|
||||
end
|
||||
specify { expect_disallowed(*member_related_permissions) }
|
||||
specify { expect_allowed(:read_group) }
|
||||
end
|
||||
|
||||
context 'with more than one owner' do
|
||||
|
@ -53,10 +97,7 @@ RSpec.describe GroupMemberPolicy do
|
|||
group.add_owner(create(:user))
|
||||
end
|
||||
|
||||
it do
|
||||
expect_allowed(:destroy_group_member)
|
||||
expect_allowed(:update_group_member)
|
||||
end
|
||||
specify { expect_allowed(*member_related_permissions) }
|
||||
end
|
||||
|
||||
context 'with the group parent' do
|
||||
|
|
|
@ -10,15 +10,13 @@ RSpec.describe 'getting group members information' do
|
|||
let_it_be(:user_1) { create(:user, username: 'user') }
|
||||
let_it_be(:user_2) { create(:user, username: 'test') }
|
||||
|
||||
let(:member_data) { graphql_data['group']['groupMembers']['edges'] }
|
||||
|
||||
before_all do
|
||||
[user_1, user_2].each { |user| parent_group.add_guest(user) }
|
||||
end
|
||||
|
||||
context 'when the request is correct' do
|
||||
it_behaves_like 'a working graphql query' do
|
||||
before_all do
|
||||
before do
|
||||
fetch_members
|
||||
end
|
||||
end
|
||||
|
@ -80,12 +78,10 @@ RSpec.describe 'getting group members information' do
|
|||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns nothing' do
|
||||
it 'returns visible members' do
|
||||
fetch_members(current_user: nil)
|
||||
|
||||
expect(graphql_errors).to be_nil
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
expect(member_data).to be_empty
|
||||
expect_array_response(user_1, user_2)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -112,8 +108,8 @@ RSpec.describe 'getting group members information' do
|
|||
|
||||
def expect_array_response(*items)
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
expect(member_data).to be_an Array
|
||||
expect(member_data.map { |node| node["node"]["user"]["id"] })
|
||||
.to match_array(items.map { |u| global_id_of(u) })
|
||||
member_gids = graphql_data_at(:group, :group_members, :edges, :node, :user, :id)
|
||||
|
||||
expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -11,15 +11,13 @@ RSpec.describe 'getting project members information' do
|
|||
let_it_be(:user_1) { create(:user, username: 'user') }
|
||||
let_it_be(:user_2) { create(:user, username: 'test') }
|
||||
|
||||
let(:member_data) { graphql_data['project']['projectMembers']['edges'] }
|
||||
|
||||
before_all do
|
||||
[user_1, user_2].each { |user| parent_group.add_guest(user) }
|
||||
end
|
||||
|
||||
context 'when the request is correct' do
|
||||
it_behaves_like 'a working graphql query' do
|
||||
before_all do
|
||||
before do
|
||||
fetch_members(project: parent_project)
|
||||
end
|
||||
end
|
||||
|
@ -114,8 +112,7 @@ RSpec.describe 'getting project members information' do
|
|||
|
||||
def expect_array_response(*items)
|
||||
expect(response).to have_gitlab_http_status(:success)
|
||||
expect(member_data).to be_an Array
|
||||
expect(member_data.map { |node| node['node']['user']['id'] })
|
||||
.to match_array(items.map { |u| global_id_of(u) })
|
||||
member_gids = graphql_data_at(:project, :project_members, :edges, :node, :user, :id)
|
||||
expect(member_gids).to match_array(items.map { |u| global_id_of(u) })
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'records an onboarding progress action' do |action|
|
||||
include AfterNextHelpers
|
||||
|
||||
it do
|
||||
expect_next(OnboardingProgressService, namespace)
|
||||
.to receive(:execute).with(action: action).and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
RSpec.shared_examples 'does not record an onboarding progress action' do
|
||||
it do
|
||||
expect(OnboardingProgressService).not_to receive(:new)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue