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 /app | |
parent | 2931472c10363925ae473bc646f53f7f58afad36 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
22 files changed, 1036 insertions, 976 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 - |