diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-18 12:11:05 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-18 12:11:05 +0300 |
commit | d7432b66ff241af3f39d82da581832a084983378 (patch) | |
tree | 02987f56a6d5119197867716faed5719e58b9880 | |
parent | 2931472c10363925ae473bc646f53f7f58afad36 (diff) |
Add latest changes from gitlab-org/gitlab@master
44 files changed, 2052 insertions, 1646 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 6143beeae45..827eca7d2b4 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -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 ( diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index ef7bfae02a2..9f0eef844f6 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue new file mode 100644 index 00000000000..35688efceb4 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_column_deprecated.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue deleted file mode 100644 index ad49936567d..00000000000 --- a/app/assets/javascripts/boards/components/board_column_new.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b366aa6fdb3..2d62dc0fa4b 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -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 : {}; diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index cb0061782a0..b6e4d0980fa 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue new file mode 100644 index 00000000000..10b0687b2a3 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 814d261e808..06f39eceb08 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue index 06f39eceb08..21147f1616c 100644 --- a/app/assets/javascripts/boards/components/board_list_header_new.vue +++ b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue deleted file mode 100644 index 726fa4a9b2b..00000000000 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 90d8f6ebc58..14d28643046 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -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> diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index 5a1b8a7672f..4fc58742783 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_new.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -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> diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index e978eedee7f..ef70a094f7c 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -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, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 0fe6d669f86..1d34f21798a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -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; diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index 50c93441dd4..4ec561014a8 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -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 diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb index 78a2be7a9f8..09cac96e3a5 100644 --- a/app/policies/group_member_policy.rb +++ b/app/policies/group_member_policy.rb @@ -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 diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 6e272f20e0a..7dd88fcc1ff 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -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 diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index a5b30e29e55..665d1035b2b 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -25,3 +25,5 @@ module Labels end end end + +Labels::CreateService.prepend_if_ee('EE::Labels::CreateService') diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index ca6efe9b095..7a34972dfbf 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -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| diff --git a/app/views/projects/snippets/verify.html.haml b/app/views/projects/snippets/verify.html.haml deleted file mode 100644 index 3c4f08e1df7..00000000000 --- a/app/views/projects/snippets/verify.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= render 'layouts/recaptcha_verification', spammable: @snippet - diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml index 245a86721eb..aa9e9a34c90 100644 --- a/app/views/shared/_recaptcha_form.html.haml +++ b/app/views/shared/_recaptcha_form.html.haml @@ -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 diff --git a/app/views/snippets/verify.html.haml b/app/views/snippets/verify.html.haml deleted file mode 100644 index 3c4f08e1df7..00000000000 --- a/app/views/snippets/verify.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -= render 'layouts/recaptcha_verification', spammable: @snippet - diff --git a/changelogs/unreleased/add-icons.yml b/changelogs/unreleased/add-icons.yml new file mode 100644 index 00000000000..35f8befa15f --- /dev/null +++ b/changelogs/unreleased/add-icons.yml @@ -0,0 +1,5 @@ +--- +title: Add Icons to headings in system info on admin panel +merge_request: 46618 +author: Yogi (@yo) +type: other diff --git a/changelogs/unreleased/ajk-group-member-policy-fix.yml b/changelogs/unreleased/ajk-group-member-policy-fix.yml new file mode 100644 index 00000000000..61b1ead3cb1 --- /dev/null +++ b/changelogs/unreleased/ajk-group-member-policy-fix.yml @@ -0,0 +1,5 @@ +--- +title: Allow more actions on group members +merge_request: 50445 +author: +type: fixed diff --git a/config/feature_flags/development/graphql_logging.yml b/config/feature_flags/development/graphql_logging.yml index 30f03a60a06..c3615215048 100644 --- a/config/feature_flags/development/graphql_logging.yml +++ b/config/feature_flags/development/graphql_logging.yml @@ -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 diff --git a/spec/controllers/concerns/spammable_actions_spec.rb b/spec/controllers/concerns/spammable_actions_spec.rb new file mode 100644 index 00000000000..3b5b4d11a9b --- /dev/null +++ b/spec/controllers/concerns/spammable_actions_spec.rb @@ -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 diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 65d8babc837..d3bdf1baaae 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -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 diff --git a/spec/frontend/boards/board_list_deprecated_spec.js b/spec/frontend/boards/board_list_deprecated_spec.js new file mode 100644 index 00000000000..393d7f954b1 --- /dev/null +++ b/spec/frontend/boards/board_list_deprecated_spec.js @@ -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(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index 80d7a72151d..f82b1f7ed5c 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -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'; diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js deleted file mode 100644 index 56784611322..00000000000 --- a/spec/frontend/boards/board_list_new_spec.js +++ /dev/null @@ -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); - }); - }); - }); -}); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 43755cd8885..1b62f25044e 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -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'; - -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({ +import BoardCard from '~/boards/components/board_card.vue'; +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, @@ -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(); - describe('When Expanded', () => { - beforeEach((done) => { - getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {})); - ({ mock, component } = createComponent({ done })); - }); - - afterEach(() => { - mock.restore(); - component.$destroy(); - }); + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); - it('loads first page of issues', () => { - return waitForPromises().then(() => { - expect(getIssues).toHaveBeenCalled(); - }); + describe('When Expanded', () => { + 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(); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + it('shows new issue form', async () => { + wrapper.vm.toggleForm(); - 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}`); - - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull(); + it('shows new issue form after eventhub event', async () => { + eventHub.$emit(`toggle-issue-form-${wrapper.vm.list.id}`); - 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'); }); + }); - 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(); + describe('load more issues', () => { + beforeEach(() => { + wrapper = createComponent({ + listProps: { issuesCount: 25 }, }); }); - it('does not load issues if already loading', () => { - component.list.nextPage = jest - .spyOn(component.list, 'nextPage') - .mockReturnValue(new Promise(() => {})); - - component.onScroll(); - component.onScroll(); + it('loads more issues after scrolling', () => { + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - return waitForPromises().then(() => { - expect(component.list.nextPage).toHaveBeenCalledTimes(1); - }); + expect(actions.fetchIssuesForList).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('does not load issues if already loading', () => { + wrapper = createComponent({ + state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } }, }); - }); - }); + wrapper.vm.listRef.dispatchEvent(new Event('scroll')); - 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); + expect(actions.fetchIssuesForList).not.toHaveBeenCalled(); }); - afterEach(() => { - mock.restore(); - component.$destroy(); - }); - - it('does not load all issues', () => { - return waitForPromises().then(() => { - // Initial getIssues from list constructor - expect(getIssues).toHaveBeenCalledTimes(1); + 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((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 } }); + + expect(wrapper.find('.bg-danger-100').exists()).toBe(false); + }); + }); + }); - return Vue.nextTick().then(() => { - expect(component.$el.querySelector('.bg-danger-100')).toBeNull(); + 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); }); }); }); diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_deprecated_spec.js index 3eebfeca965..8236b468189 100644 --- a/spec/frontend/boards/board_new_issue_spec.js +++ b/spec/frontend/boards/board_new_issue_deprecated_spec.js @@ -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'; diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_deprecated_spec.js index 81c0e60f931..a703caca4eb 100644 --- a/spec/frontend/boards/components/board_column_new_spec.js +++ b/spec/frontend/boards/components/board_column_deprecated_spec.js @@ -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; + + 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(); }); - const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => { + 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', () => { diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index ba11225676b..1dcdad2b492 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -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', () => { diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index 291013c561e..98be02d7dbf 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -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); }); }); diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_deprecated_spec.js index 7fae2108e52..6207724e6a9 100644 --- a/spec/frontend/boards/components/board_list_header_new_spec.js +++ b/spec/frontend/boards/components/board_list_header_deprecated_spec.js @@ -1,23 +1,28 @@ -import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; - -import { mockLabelList } from 'jest/boards/mock_data'; -import BoardListHeader from '~/boards/components/board_list_header_new.vue'; +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 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'); - await wrapper.vm.$nextTick(); - - expect(isCollapsed()).toBe(false); + return wrapper.vm.$nextTick().then(() => { + expect(isCollapsed()).toBe(false); + }); }); - 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 in it calls list update and doesn't set localStorage", () => { + jest.spyOn(List.prototype, 'update'); + window.gon.current_user_id = 1; - it("when logged out it doesn't call list update and sets localStorage", async () => { - createComponent(); + createComponent({ withLocalStorage: false }); findCaret().vm.$emit('click'); - await wrapper.vm.$nextTick(); - expect(updateListSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded())); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); + expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); + }); }); - }); - describe('user can drag', () => { - const cannotDragList = [ListType.backlog, ListType.closed]; - const canDragList = [ListType.label, ListType.milestone, ListType.assignee]; + it("when logged out it doesn't call list update and sets localStorage", () => { + jest.spyOn(List.prototype, 'update'); - 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'); - }, - ); + createComponent(); - it.each(canDragList)('has user-can-drag-class so user can drag list', (listType) => { - createComponent({ listType }); + findCaret().vm.$emit('click'); - 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())); + }); }); }); }); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 58469a1ac9a..357d05ced02 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -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); - }); - }); + await wrapper.vm.$nextTick(); - it("when logged in it calls list update and doesn't set localStorage", () => { - jest.spyOn(List.prototype, 'update'); - window.gon.current_user_id = 1; + expect(isCollapsed()).toBe(false); + }); - createComponent({ withLocalStorage: false }); + it("when logged in it calls list update and doesn't set localStorage", async () => { + createComponent({ withLocalStorage: false, currentUserId: 1 }); findCaret().vm.$emit('click'); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1); - expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null); - }); + 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", () => { - jest.spyOn(List.prototype, 'update'); - + 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'); }); }); }); diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_spec.js index 3265ef40a2e..5a01221a5be 100644 --- a/spec/frontend/boards/components/board_new_issue_new_spec.js +++ b/spec/frontend/boards/components/board_new_issue_spec.js @@ -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'; diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index a963a92a366..d5cfb9b7d07 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -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, diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 72f81d6db29..e4209cd5e55 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -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' }], }, }, diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb index 9e58ea81ef3..6099e4549b1 100644 --- a/spec/policies/group_member_policy_spec.rb +++ b/spec/policies/group_member_policy_spec.rb @@ -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 diff --git a/spec/requests/api/graphql/group/group_members_spec.rb b/spec/requests/api/graphql/group/group_members_spec.rb index 3554e22cdf2..452610ab18f 100644 --- a/spec/requests/api/graphql/group/group_members_spec.rb +++ b/spec/requests/api/graphql/group/group_members_spec.rb @@ -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 diff --git a/spec/requests/api/graphql/project/project_members_spec.rb b/spec/requests/api/graphql/project/project_members_spec.rb index cb937432ef7..984a0adb8c6 100644 --- a/spec/requests/api/graphql/project/project_members_spec.rb +++ b/spec/requests/api/graphql/project/project_members_spec.rb @@ -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 diff --git a/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb b/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb new file mode 100644 index 00000000000..8c6c2271af3 --- /dev/null +++ b/spec/support/shared_examples/services/onboarding_progress_shared_examples.rb @@ -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 |