diff options
author | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 13:34:23 -0600 |
---|---|---|
committer | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 13:34:23 -0600 |
commit | 6438df3a1e0fb944485cebf07976160184697d72 (patch) | |
tree | 00b09bfd170e77ae9391b1a2f5a93ef6839f2597 /app/assets/javascripts/boards | |
parent | 42bcd54d971da7ef2854b896a7b34f4ef8601067 (diff) | |
download | gitlab-ce-6438df3a1e0fb944485cebf07976160184697d72.tar.gz |
Add latest changes from gitlab-org/gitlab@13-8-stable-eev13.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
55 files changed, 2246 insertions, 1415 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index e5ff41dab74..965d3571f42 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,5 +1,4 @@ import { sortBy } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -42,14 +41,14 @@ export function formatListIssues(listIssues) { const listData = listIssues.nodes.reduce((map, list) => { listIssuesCount = list.issues.count; - let sortedIssues = list.issues.edges.map(issueNode => ({ + let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); sortedIssues = sortBy(sortedIssues, 'relativePosition'); return { ...map, - [list.id]: sortedIssues.map(i => { + [list.id]: sortedIssues.map((i) => { const id = getIdFromGraphQLId(i.id); const listIssue = { @@ -83,49 +82,64 @@ export function fullBoardId(boardId) { return `gid://gitlab/Board/${boardId}`; } +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 ( toList.listType === ListType.label && - !updatedIssue.labels.find(label => label.id === toList.label.id) + !updatedIssue.labels.find((label) => label.id === toList.label.id) ) { updatedIssue.labels.push(toList.label); } if (fromList?.label && fromList.listType === ListType.label) { - updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id); + updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id); } if ( toList.listType === ListType.assignee && - !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id) + !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id) ) { updatedIssue.assignees.push(toList.assignee); } if (fromList?.assignee && fromList.listType === ListType.assignee) { updatedIssue.assignees = updatedIssue.assignees.filter( - assignee => assignee.id !== fromList.assignee.id, + (assignee) => assignee.id !== fromList.assignee.id, ); } return updatedIssue; } -export function getBoardsPath(endpoint, board) { - const path = `${endpoint}${board.id ? `/${board.id}` : ''}.json`; - - if (board.id) { - return axios.put(path, { board }); - } - return axios.post(path, { board }); -} - export function isListDraggable(list) { return list.listType !== ListType.backlog && list.listType !== ListType.closed; } @@ -141,6 +155,6 @@ export default { formatListIssues, fullBoardId, fullLabelId, - getBoardsPath, + fullIterationId, isListDraggable, }; diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index 1469efae5a6..5d381f9a570 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -119,7 +119,7 @@ export default { this.selected = this.selected.concat(name); }, unselect(name) { - this.selected = this.selected.filter(user => user.username !== name); + this.selected = this.selected.filter((user) => user.username !== name); }, saveAssignees() { this.setAssignees(this.selectedUserNames); diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index f796acd2303..0a2301394c1 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -1,12 +1,17 @@ <script> +import { mapActions, mapGetters } from 'vuex'; import IssueCardInner from './issue_card_inner.vue'; +import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; import boardsStore from '../stores/boards_store'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ISSUABLE } from '~/boards/constants'; export default { - name: 'BoardsIssueCard', + name: 'BoardCardLayout', components: { - IssueCardInner, + IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, }, + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -41,11 +46,13 @@ export default { }; }, computed: { + ...mapGetters(['isSwimlanesOn']), multiSelectVisible() { - return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; + return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1; }, }, methods: { + ...mapActions(['setActiveId']), mouseDown() { this.showDetail = true; }, @@ -56,6 +63,11 @@ export default { // Don't do anything if this happened on a no trigger element if (e.target.classList.contains('js-no-trigger')) return; + if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) { + this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); + return; + } + const isMultiSelect = e.ctrlKey || e.metaKey; if (this.showDetail || isMultiSelect) { @@ -80,7 +92,7 @@ export default { :data-issue-iid="issue.iid" :data-issue-path="issue.referencePath" data-testid="board_card" - class="board-card p-3 rounded" + class="board-card gl-p-5 gl-rounded-base" @mousedown="mouseDown" @mousemove="mouseMove" @mouseup="showIssue($event)" diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 753e6941c43..9f0eef844f6 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,16 +1,19 @@ <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: { BoardListHeader, BoardList, }, + inject: { + boardId: { + default: '', + }, + }, props: { list: { type: Object, @@ -27,58 +30,27 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - }, - 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 7839f45c48b..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, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - required: true, - }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, - }, - inject: { - boardId: { - default: '', - }, - }, - 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_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index 99d1e4a2611..b8ee930a8c9 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -6,36 +6,13 @@ export default { GlFormCheckbox, }, props: { - currentBoard: { - type: Object, - required: true, - }, - board: { - type: Object, + hideBacklogList: { + type: Boolean, required: true, }, - isNewForm: { + hideClosedList: { type: Boolean, - required: false, - default: false, - }, - }, - data() { - const { hide_backlog_list: hideBacklogList, hide_closed_list: hideClosedList } = this.isNewForm - ? this.board - : this.currentBoard; - - return { - hideClosedList, - hideBacklogList, - }; - }, - methods: { - changeClosedList(checked) { - this.board.hideClosedList = !checked; - }, - changeBacklogList(checked) { - this.board.hideBacklogList = !checked; + required: true, }, }, }; @@ -52,13 +29,13 @@ export default { <gl-form-checkbox :checked="!hideBacklogList" data-testid="backlog-list-checkbox" - @change="changeBacklogList" + @change="$emit('update:hideBacklogList', !hideBacklogList)" >{{ __('Show the Open list') }} </gl-form-checkbox> <gl-form-checkbox :checked="!hideClosedList" data-testid="closed-list-checkbox" - @change="changeClosedList" + @change="$emit('update:hideClosedList', !hideClosedList)" >{{ __('Show the Closed list') }} </gl-form-checkbox> </div> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b366aa6fdb3..19254343208 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 : {}; @@ -108,14 +109,14 @@ export default { /> </component> - <template v-else> - <epics-swimlanes - ref="swimlanes" - :lists="boardListsToUse" - :can-admin-list="canAdminList" - :disabled="disabled" - /> - <board-content-sidebar /> - </template> + <epics-swimlanes + v-else + ref="swimlanes" + :lists="boardListsToUse" + :can-admin-list="canAdminList" + :disabled="disabled" + /> + + <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index dab934352ca..c701ecd3040 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,20 +1,24 @@ <script> import { GlModal } from '@gitlab/ui'; -import { pick } from 'lodash'; import { __, s__ } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; +import { getParameterByName } from '~/lib/utils/common_utils'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import boardsStore from '~/boards/stores/boards_store'; -import { fullBoardId, getBoardsPath } from '../boards_util'; +import { fullLabelId, fullBoardId } from '../boards_util'; import BoardConfigurationOptions from './board_configuration_options.vue'; -import createBoardMutation from '../graphql/board.mutation.graphql'; +import updateBoardMutation from '../graphql/board_update.mutation.graphql'; +import createBoardMutation from '../graphql/board_create.mutation.graphql'; +import destroyBoardMutation from '../graphql/board_destroy.mutation.graphql'; const boardDefaults = { id: false, name: '', labels: [], milestone_id: undefined, + iteration_id: undefined, assignee: {}, assignee_id: undefined, weight: null, @@ -46,6 +50,14 @@ export default { GlModal, BoardConfigurationOptions, }, + inject: { + fullPath: { + default: '', + }, + rootPath: { + default: '', + }, + }, props: { canAdminBoard: { type: Boolean, @@ -89,11 +101,6 @@ export default { required: true, }, }, - inject: { - endpoints: { - default: {}, - }, - }, data() { return { board: { ...boardDefaults, ...this.currentBoard }, @@ -154,14 +161,44 @@ export default { text: this.$options.i18n.cancelButtonText, }; }, - boardPayload() { - const { assignee, milestone, labels } = this.board; - return { - ...this.board, - assignee_id: assignee?.id, - milestone_id: milestone?.id, - label_ids: labels.length ? labels.map(b => b.id) : [''], + currentMutation() { + return this.board.id ? updateBoardMutation : createBoardMutation; + }, + mutationVariables() { + const { board } = this; + /* eslint-disable @gitlab/require-i18n-strings */ + let baseMutationVariables = { + name: board.name, + hideBacklogList: board.hide_backlog_list, + hideClosedList: board.hide_closed_list, }; + + if (this.scopedIssueBoardFeatureEnabled) { + baseMutationVariables = { + ...baseMutationVariables, + weight: board.weight, + assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null, + milestoneId: + board.milestone?.id || board.milestone?.id === 0 + ? convertToGraphQLId('Milestone', board.milestone.id) + : null, + labelIds: board.labels.map(fullLabelId), + iterationId: board.iteration_id + ? convertToGraphQLId('Iteration', board.iteration_id) + : null, + }; + } + /* eslint-enable @gitlab/require-i18n-strings */ + return board.id + ? { + ...baseMutationVariables, + id: fullBoardId(board.id), + } + : { + ...baseMutationVariables, + projectPath: this.projectId ? this.fullPath : null, + groupPath: this.groupId ? this.fullPath : null, + }; }, }, mounted() { @@ -171,55 +208,51 @@ export default { } }, methods: { - callBoardMutation(id) { - return this.$apollo.mutate({ - mutation: createBoardMutation, - variables: { - ...pick(this.boardPayload, ['hideClosedList', 'hideBacklogList']), - id, - }, - }); + setIteration(iterationId) { + this.board.iteration_id = iterationId; }, - async updateBoard() { - const responses = await Promise.all([ - // Remove unnecessary REST API call when https://gitlab.com/gitlab-org/gitlab/-/issues/282299#note_462996301 is resolved - getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload), - this.callBoardMutation(fullBoardId(this.boardPayload.id)), - ]); + async createOrUpdateBoard() { + const response = await this.$apollo.mutate({ + mutation: this.currentMutation, + variables: { input: this.mutationVariables }, + }); - return responses[0].data; - }, - async createBoard() { - // TODO: change this to use `createBoard` mutation https://gitlab.com/gitlab-org/gitlab/-/issues/292466 is resolved - const boardData = await getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload); - this.callBoardMutation(fullBoardId(boardData.data.id)); + if (!this.board.id) { + return response.data.createBoard.board.webPath; + } - return boardData.data || boardData; + const path = response.data.updateBoard.board.webPath; + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + return `${path}${param}`; }, - submit() { + async submit() { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { - boardsStore - .deleteBoard(this.currentBoard) - .then(() => { - this.isLoading = false; - visitUrl(boardsStore.rootPath); - }) - .catch(() => { - Flash(this.$options.i18n.deleteErrorMessage); - this.isLoading = false; + try { + await this.$apollo.mutate({ + mutation: destroyBoardMutation, + variables: { + id: fullBoardId(this.board.id), + }, }); + visitUrl(this.rootPath); + } catch { + Flash(this.$options.i18n.deleteErrorMessage); + } finally { + this.isLoading = false; + } } else { - const boardAction = this.boardPayload.id ? this.updateBoard : this.createBoard; - boardAction() - .then(data => { - visitUrl(data.board_path); - }) - .catch(() => { - Flash(this.$options.i18n.saveErrorMessage); - this.isLoading = false; - }); + try { + const url = await this.createOrUpdateBoard(); + visitUrl(url); + } catch { + Flash(this.$options.i18n.saveErrorMessage); + } finally { + this.isLoading = false; + } } }, cancel() { @@ -273,9 +306,8 @@ export default { </div> <board-configuration-options - :is-new-form="isNewForm" - :board="board" - :current-board="currentBoard" + :hide-backlog-list.sync="board.hide_backlog_list" + :hide-closed-list.sync="board.hide_closed_list" /> <board-scope @@ -289,6 +321,7 @@ export default { :project-id="projectId" :group-id="groupId" :weights="weights" + @set-iteration="setIteration" /> </form> </gl-modal> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1f87b563e73..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..24900346bda --- /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 3db5c2e0830..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, @@ -31,6 +37,20 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + boardId: { + default: '', + }, + weightFeatureAvailable: { + default: false, + }, + scopedLabelsAvailable: { + default: false, + }, + currentUserId: { + default: null, + }, + }, props: { list: { type: Object, @@ -47,61 +67,53 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - }, - 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 44eb2aa34c2..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, @@ -37,6 +33,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + boardId: { + default: '', + }, + }, props: { list: { type: Object, @@ -53,67 +54,56 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - weightFeatureAvailable: { - default: false, - }, - scopedLabelsAvailable: { - default: false, - }, - currentUserId: { - default: null, - }, + 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 92a381a8f57..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 a9e6d768656..14d28643046 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,94 +1,85 @@ <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 boardsStore from '../stores/boards_store'; 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', 'weightFeatureAvailable', 'boardWeight'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId'], 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 969c84ddb59..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,33 +1,32 @@ <script> -import { mapActions } 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'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], data() { return { title: '', + error: false, selectedProject: {}, }; }, @@ -38,45 +37,52 @@ export default { } 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.path, - 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}`); }, @@ -90,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" @@ -110,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/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 60db8fefe82..f362fc60bd3 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -116,7 +116,7 @@ export default { v-if="isWipLimitsOn" :max-issue-count="activeList.maxIssueCount" /> - <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> + <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4"> <gl-button variant="danger" category="secondary" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index d26f15c1723..bf3dc5c608f 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -68,7 +68,7 @@ export default Vue.extend({ : __('Label'); }, selectedLabels() { - return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : ''; + return this.hasLabels ? this.issue.labels.map((l) => l.title).join(',') : ''; }, }, watch: { @@ -82,9 +82,7 @@ export default Vue.extend({ }); $('.js-issue-board-sidebar', this.$el).each((i, el) => { - $(el) - .data('deprecatedJQueryDropdown') - .clearMenu(); + $(el).data('deprecatedJQueryDropdown').clearMenu(); }); } diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 4f23c38d0f7..fcd1c3fdceb 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -118,7 +118,7 @@ export default { return this.state.currentPage; }, filteredBoards() { - return this.boards.filter(board => + return this.boards.filter((board) => board.name.toLowerCase().includes(this.filterTerm.toLowerCase()), ); }, @@ -181,10 +181,10 @@ export default { this.loadingRecentBoards = true; boardsStore .recentBoards() - .then(res => { + .then((res) => { this.recentBoards = res.data; }) - .catch(err => { + .catch((err) => { /** * If user is unauthorized we'd still want to resolve the * request to display all boards. diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index ddd20ff281c..457d0d4dcd6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,6 +1,6 @@ <script> import { sortBy } from 'lodash'; -import { mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import { sprintf, __, n__ } from '~/locale'; @@ -8,9 +8,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; -import boardsStore from '../stores/boards_store'; +import eventHub from '../eventhub'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { ListType } from '../constants'; +import { updateHistory } from '~/lib/utils/url_utility'; export default { components: { @@ -26,6 +27,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [issueCardInner], + inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'], props: { issue: { type: Object, @@ -42,7 +44,6 @@ export default { default: false, }, }, - inject: ['groupId', 'rootPath'], data() { return { limitBeforeCounter: 2, @@ -52,6 +53,16 @@ export default { }, computed: { ...mapState(['isShowingLabels']), + cappedAssignees() { + // e.g. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return this.issue.assignees.slice(0, this.maxRender); + } + + return this.issue.assignees.slice(0, this.limitBeforeCounter); + }, numberOverLimit() { return this.issue.assignees.length - this.limitBeforeCounter; }, @@ -98,19 +109,10 @@ export default { }, }, methods: { + ...mapActions(['performSearch']), isIndexLessThanlimit(index) { return index < this.limitBeforeCounter; }, - shouldRenderAssignee(index) { - // Eg. maxRender is 4, - // Render up to all 4 assignees if there are only 4 assigness - // Otherwise render up to the limitBeforeCounter - if (this.issue.assignees.length <= this.maxRender) { - return index < this.maxRender; - } - - return index < this.limitBeforeCounter; - }, assigneeUrl(assignee) { if (!assignee) return ''; return `${this.rootPath}${assignee.username}`; @@ -118,6 +120,9 @@ export default { avatarUrlTitle(assignee) { return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name }); }, + avatarUrl(assignee) { + return assignee.avatarUrl || assignee.avatar || gon.default_avatar_url; + }, showLabel(label) { if (!label.id) return false; return true; @@ -133,13 +138,19 @@ export default { }, filterByLabel(label) { if (!this.updateFilters) return; - const labelTitle = encodeURIComponent(label.title); - const filter = `label_name[]=${labelTitle}`; + const filterPath = window.location.search ? `${window.location.search}&` : '?'; + const filter = `label_name[]=${encodeURIComponent(label.title)}`; - boardsStore.toggleFilter(filter); + if (!filterPath.includes(filter)) { + updateHistory({ + url: `${filterPath}${filter}`, + }); + this.performSearch(); + eventHub.$emit('updateTokens'); + } }, showScopedLabel(label) { - return boardsStore.scopedLabels.enabled && isScopedLabel(label); + return this.scopedLabelsAvailable && isScopedLabel(label); }, }, }; @@ -222,12 +233,11 @@ export default { </div> <div class="board-card-assignee gl-display-flex"> <user-avatar-link - v-for="(assignee, index) in issue.assignees" - v-if="shouldRenderAssignee(index)" + v-for="assignee in cappedAssignees" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" + :img-src="avatarUrl(assignee)" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue new file mode 100644 index 00000000000..75cf1f0b9e1 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -0,0 +1,245 @@ +<script> +import { sortBy } from 'lodash'; +import { mapState } from 'vuex'; +import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; +import { sprintf, __, n__ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import IssueDueDate from './issue_due_date.vue'; +import IssueTimeEstimate from './issue_time_estimate_deprecated.vue'; +import boardsStore from '../stores/boards_store'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlLabel, + GlIcon, + UserAvatarLink, + TooltipOnTruncate, + IssueDueDate, + IssueTimeEstimate, + IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [issueCardInner], + inject: ['groupId', 'rootPath'], + props: { + issue: { + type: Object, + required: true, + }, + list: { + type: Object, + required: false, + default: () => ({}), + }, + updateFilters: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + limitBeforeCounter: 2, + maxRender: 3, + maxCounter: 99, + }; + }, + computed: { + ...mapState(['isShowingLabels']), + numberOverLimit() { + return this.issue.assignees.length - this.limitBeforeCounter; + }, + assigneeCounterTooltip() { + const { numberOverLimit, maxCounter } = this; + const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit; + return sprintf(__('%{count} more assignees'), { count }); + }, + assigneeCounterLabel() { + if (this.numberOverLimit > this.maxCounter) { + return `${this.maxCounter}+`; + } + + return `+${this.numberOverLimit}`; + }, + shouldRenderCounter() { + if (this.issue.assignees.length <= this.maxRender) { + return false; + } + + return this.issue.assignees.length > this.numberOverLimit; + }, + issueId() { + if (this.issue.iid) { + return `#${this.issue.iid}`; + } + return false; + }, + showLabelFooter() { + return this.isShowingLabels && this.issue.labels.find(this.showLabel); + }, + issueReferencePath() { + const { referencePath, groupId } = this.issue; + return !groupId ? referencePath.split('#')[0] : null; + }, + orderedLabels() { + return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); + }, + blockedLabel() { + if (this.issue.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + } + return __('Blocked issue'); + }, + }, + methods: { + isIndexLessThanlimit(index) { + return index < this.limitBeforeCounter; + }, + shouldRenderAssignee(index) { + // Eg. maxRender is 4, + // Render up to all 4 assignees if there are only 4 assigness + // Otherwise render up to the limitBeforeCounter + if (this.issue.assignees.length <= this.maxRender) { + return index < this.maxRender; + } + + return index < this.limitBeforeCounter; + }, + assigneeUrl(assignee) { + if (!assignee) return ''; + return `${this.rootPath}${assignee.username}`; + }, + avatarUrlTitle(assignee) { + return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name }); + }, + showLabel(label) { + if (!label.id) return false; + return true; + }, + isNonListLabel(label) { + return label.id && !(this.list.type === 'label' && this.list.title === label.title); + }, + filterByLabel(label) { + if (!this.updateFilters) return; + const labelTitle = encodeURIComponent(label.title); + const filter = `label_name[]=${labelTitle}`; + + boardsStore.toggleFilter(filter); + }, + showScopedLabel(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex" dir="auto"> + <h4 class="board-card-title gl-mb-0 gl-mt-0"> + <gl-icon + v-if="issue.blocked" + v-gl-tooltip + name="issue-block" + :title="blockedLabel" + class="issue-blocked-icon gl-mr-2" + :aria-label="blockedLabel" + data-testid="issue-blocked-icon" + /> + <gl-icon + v-if="issue.confidential" + v-gl-tooltip + name="eye-slash" + :title="__('Confidential')" + class="confidential-icon gl-mr-2" + :aria-label="__('Confidential')" + /> + <a + :href="issue.path || issue.webUrl || ''" + :title="issue.title" + class="js-no-trigger" + @mousemove.stop + >{{ issue.title }}</a + > + </h4> + </div> + <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> + <template v-for="label in orderedLabels"> + <gl-label + :key="label.id" + :background-color="label.color" + :title="label.title" + :description="label.description" + size="sm" + :scoped="showScopedLabel(label)" + @click="filterByLabel(label)" + /> + </template> + </div> + <div + class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end" + > + <div + class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" + > + <span + v-if="issue.referencePath" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" + > + <tooltip-on-truncate + v-if="issueReferencePath" + :title="issueReferencePath" + placement="bottom" + class="board-issue-path gl-text-truncate gl-font-weight-bold" + >{{ issueReferencePath }}</tooltip-on-truncate + > + #{{ issue.iid }} + </span> + <span class="board-info-items gl-mt-3 gl-display-inline-block"> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate" + :closed="issue.closed || Boolean(issue.closedAt)" + /> + <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> + <issue-card-weight + v-if="validIssueWeight" + :weight="issue.weight" + @click="filterByWeight(issue.weight)" + /> + </span> + </div> + <div class="board-card-assignee gl-display-flex"> + <user-avatar-link + v-for="(assignee, index) in issue.assignees" + v-if="shouldRenderAssignee(index)" + :key="assignee.id" + :link-href="assigneeUrl(assignee)" + :img-alt="avatarUrlTitle(assignee)" + :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" + :img-size="24" + class="js-no-trigger" + tooltip-placement="bottom" + > + <span class="js-assignee-tooltip"> + <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> + {{ assignee.name }} + <span class="text-white-50">@{{ assignee.username }}</span> + </span> + </user-avatar-link> + <span + v-if="shouldRenderCounter" + v-gl-tooltip + :title="assigneeCounterTooltip" + class="avatar-counter" + data-placement="bottom" + >{{ assigneeCounterLabel }}</span + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index fe56833016e..f6b00b695da 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -1,30 +1,34 @@ <script> import { GlTooltip, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; -import boardsStore from '../stores/boards_store'; export default { + i18n: { + timeEstimate: __('Time estimate'), + }, components: { GlIcon, GlTooltip, }, + inject: ['timeTrackingLimitToHours'], props: { estimate: { type: Number, required: true, }, }, - data() { - return { - limitToHours: boardsStore.timeTracking.limitToHours, - }; - }, computed: { title() { - return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true); + return stringifyTime( + parseSeconds(this.estimate, { limitToHours: this.timeTrackingLimitToHours }), + true, + ); }, timeEstimate() { - return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours })); + return stringifyTime( + parseSeconds(this.estimate, { limitToHours: this.timeTrackingLimitToHours }), + ); }, }, }; @@ -33,16 +37,16 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{ - timeEstimate - }}</time> + <gl-icon name="hourglass" class="board-card-info-icon" /> + <time class="board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip :target="() => $refs.issueTimeEstimate" placement="bottom" class="js-issue-time-estimate" > - <span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }} + <span class="gl-font-weight-bold gl-display-block">{{ $options.i18n.timeEstimate }}</span> + {{ title }} </gl-tooltip> </span> </template> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue new file mode 100644 index 00000000000..fe56833016e --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue @@ -0,0 +1,48 @@ +<script> +import { GlTooltip, GlIcon } from '@gitlab/ui'; +import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; +import boardsStore from '../stores/boards_store'; + +export default { + components: { + GlIcon, + GlTooltip, + }, + props: { + estimate: { + type: Number, + required: true, + }, + }, + data() { + return { + limitToHours: boardsStore.timeTracking.limitToHours, + }; + }, + computed: { + title() { + return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true); + }, + timeEstimate() { + return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours })); + }, + }, +}; +</script> + +<template> + <span> + <span ref="issueTimeEstimate" class="board-card-info card-number"> + <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{ + timeEstimate + }}</time> + </span> + <gl-tooltip + :target="() => $refs.issueTimeEstimate" + placement="bottom" + class="js-issue-time-estimate" + > + <span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }} + </gl-tooltip> + </span> +</template> diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index d28a03da97f..10c29977cae 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -40,21 +40,21 @@ export default { const firstListIndex = 1; const list = this.modal.selectedList || this.state.lists[firstListIndex]; const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map(issue => issue.id); + const issueIds = selectedIssues.map((issue) => issue.id); const req = this.buildUpdateRequest(list); // Post the data to the backend boardsStore.bulkUpdate(issueIds, req).catch(() => { Flash(__('Failed to update issues, please try again.')); - selectedIssues.forEach(issue => { + selectedIssues.forEach((issue) => { list.removeIssue(issue); list.issuesSize -= 1; }); }); // Add the issues on the frontend - selectedIssues.forEach(issue => { + selectedIssues.forEach((issue) => { list.addIssue(issue); list.issuesSize += 1; }); diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 817b3bdddb0..84d687a46b9 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -65,9 +65,7 @@ export default { this.loading = false; }; - this.loadIssues() - .then(loadingDone) - .catch(loadingDone); + this.loadIssues().then(loadingDone).catch(loadingDone); } else if (!this.showAddIssuesModal) { this.issues = []; this.selectedIssues = []; @@ -83,9 +81,7 @@ export default { this.filterLoading = false; }; - this.loadIssues(true) - .then(loadingDone) - .catch(loadingDone); + this.loadIssues(true).then(loadingDone).catch(loadingDone); } }, deep: true, @@ -104,13 +100,13 @@ export default { page: this.page, per: this.perPage, }) - .then(res => res.data) - .then(data => { + .then((res) => res.data) + .then((data) => { if (clearIssues) { this.issues = []; } - data.issues.forEach(issueObj => { + data.issues.forEach((issueObj) => { const issue = new ListIssue(issueObj); const foundSelectedIssue = ModalStore.findSelectedIssue(issue); issue.selected = Boolean(foundSelectedIssue); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index d1011c24977..2bc54155163 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -40,7 +40,7 @@ $(document) }); export default function initNewListDropdown() { - $('.js-new-board-list').each(function() { + $('.js-new-board-list').each(function () { const $dropdownToggle = $(this); const $dropdown = $dropdownToggle.closest('.dropdown'); new CreateLabelDropdown( diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 9c90938fc52..04699d0d3a4 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,120 +1,141 @@ <script> -import $ from 'jquery'; -import { escape } from 'lodash'; -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; -import eventHub from '../eventhub'; -import Api from '../../api'; +import { mapActions, mapState } from 'vuex'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { ListType } from '../constants'; export default { - name: 'BoardProjectSelect', + name: 'ProjectSelect', + i18n: { + headerTitle: s__(`BoardNewIssue|Projects`), + dropdownText: s__(`BoardNewIssue|Select a project`), + searchPlaceholder: s__(`BoardNewIssue|Search projects`), + emptySearchResult: s__(`BoardNewIssue|No matching results`), + }, + defaultFetchOptions: { + with_issues_enabled: true, + with_shared: false, + include_subgroups: true, + order_by: 'similarity', + }, components: { - GlIcon, + GlIntersectionObserver, GlLoadingIcon, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, }, + inject: ['groupId'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId'], data() { return { - loading: true, + initialLoading: true, selectedProject: {}, + searchTerm: '', }; }, computed: { + ...mapState(['groupProjects', 'groupProjectsFlags']), selectedProjectName() { - return this.selectedProject.name || __('Select a project'); + return this.selectedProject.name || this.$options.i18n.dropdownText; + }, + fetchOptions() { + const additionalAttrs = {}; + if (this.list.type && this.list.type !== ListType.backlog) { + additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; + } + + return { + ...this.$options.defaultFetchOptions, + ...additionalAttrs, + }; + }, + isFetchResultEmpty() { + return this.groupProjects.length === 0; + }, + hasNextPage() { + return this.groupProjectsFlags.pageInfo?.hasNextPage; + }, + }, + watch: { + searchTerm() { + this.fetchGroupProjects({ search: this.searchTerm }); }, }, mounted() { - initDeprecatedJQueryDropdown($(this.$refs.projectsDropdown), { - filterable: true, - filterRemote: true, - search: { - fields: ['name_with_namespace'], - }, - clicked: ({ $el, e }) => { - e.preventDefault(); - this.selectedProject = { - id: $el.data('project-id'), - name: $el.data('project-name'), - path: $el.data('project-path'), - }; - eventHub.$emit('setSelectedProject', this.selectedProject); - }, - selectable: true, - data: (term, callback) => { - this.loading = true; - const additionalAttrs = {}; + this.fetchGroupProjects({}); - if ((this.list.type || this.list.listType) !== ListType.backlog) { - additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; - } - - return Api.groupProjects( - this.groupId, - term, - { - with_issues_enabled: true, - with_shared: false, - include_subgroups: true, - order_by: 'similarity', - ...additionalAttrs, - }, - projects => { - this.loading = false; - callback(projects); - }, - ); - }, - renderRow(project) { - return ` - <li> - <a href='#' class='dropdown-menu-link' - data-project-id="${project.id}" - data-project-name="${project.name}" - data-project-name-with-namespace="${project.name_with_namespace}" - data-project-path="${project.path_with_namespace}" - > - ${escape(project.name_with_namespace)} - </a> - </li> - `; - }, - text: project => project.name_with_namespace, - }); + this.initialLoading = false; + }, + methods: { + ...mapActions(['fetchGroupProjects', 'setSelectedProject']), + selectProject(projectId) { + this.selectedProject = this.groupProjects.find((project) => project.id === projectId); + this.setSelectedProject(this.selectedProject); + }, + loadMoreProjects() { + this.fetchGroupProjects({ search: this.searchTerm, fetchNext: true }); + }, }, }; </script> <template> <div> - <label class="label-bold gl-mt-3">{{ __('Project') }}</label> - <div ref="projectsDropdown" class="dropdown dropdown-projects"> - <button - class="dropdown-menu-toggle wide" - type="button" - data-toggle="dropdown" - aria-expanded="false" + <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{ + $options.i18n.headerTitle + }}</label> + <gl-dropdown + data-testid="project-select-dropdown" + :text="selectedProjectName" + :header-text="$options.i18n.headerTitle" + block + menu-class="gl-w-full!" + :loading="initialLoading" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + debounce="250" + :placeholder="$options.i18n.searchPlaceholder" + /> + <gl-dropdown-item + v-for="project in groupProjects" + v-show="!groupProjectsFlags.isLoading" + :key="project.id" + :name="project.name" + @click="selectProject(project.id)" + > + {{ project.nameWithNamespace }} + </gl-dropdown-item> + <gl-dropdown-text + v-show="groupProjectsFlags.isLoading" + data-testid="dropdown-text-loading-icon" + > + <gl-loading-icon class="gl-mx-auto" /> + </gl-dropdown-text> + <gl-dropdown-text + v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading" + data-testid="empty-result-message" > - {{ selectedProjectName }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" /> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> - <div class="dropdown-title">{{ __('Projects') }}</div> - <div class="dropdown-input"> - <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" /> - <gl-icon name="search" class="dropdown-input-search" data-hidden="true" /> - </div> - <div class="dropdown-content"></div> - <div class="dropdown-loading"><gl-loading-icon /></div> - </div> - </div> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + <gl-intersection-observer v-if="hasNextPage" @appear="loadMoreProjects"> + <gl-loading-icon v-if="groupProjectsFlags.isLoadingMore" size="md" /> + </gl-intersection-observer> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue new file mode 100644 index 00000000000..a043dc575ca --- /dev/null +++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue @@ -0,0 +1,145 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlLoadingIcon, +} from '@gitlab/ui'; +import eventHub from '../eventhub'; +import { s__ } from '~/locale'; +import Api from '../../api'; +import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import { ListType } from '../constants'; + +export default { + name: 'ProjectSelect', + i18n: { + headerTitle: s__(`BoardNewIssue|Projects`), + dropdownText: s__(`BoardNewIssue|Select a project`), + searchPlaceholder: s__(`BoardNewIssue|Search projects`), + emptySearchResult: s__(`BoardNewIssue|No matching results`), + }, + defaultFetchOptions: { + with_issues_enabled: true, + with_shared: false, + include_subgroups: true, + order_by: 'similarity', + }, + components: { + GlLoadingIcon, + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + }, + inject: ['groupId'], + props: { + list: { + type: Object, + required: true, + }, + }, + data() { + return { + initialLoading: true, + isFetching: false, + projects: [], + selectedProject: {}, + searchTerm: '', + }; + }, + computed: { + selectedProjectName() { + return this.selectedProject.name || this.$options.i18n.dropdownText; + }, + fetchOptions() { + const additionalAttrs = {}; + if (this.list.type && this.list.type !== ListType.backlog) { + additionalAttrs.min_access_level = featureAccessLevel.EVERYONE; + } + + return { + ...this.$options.defaultFetchOptions, + ...additionalAttrs, + }; + }, + isFetchResultEmpty() { + return this.projects.length === 0; + }, + }, + watch: { + searchTerm() { + this.fetchProjects(); + }, + }, + async mounted() { + await this.fetchProjects(); + + this.initialLoading = false; + }, + methods: { + async fetchProjects() { + this.isFetching = true; + try { + const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions); + + this.projects = projects.map((project) => { + return { + id: project.id, + name: project.name, + namespacedName: project.name_with_namespace, + path: project.path_with_namespace, + }; + }); + } catch (err) { + /* Handled in Api.groupProjects */ + } finally { + this.isFetching = false; + } + }, + selectProject(projectId) { + this.selectedProject = this.projects.find((project) => project.id === projectId); + + eventHub.$emit('setSelectedProject', this.selectedProject); + }, + }, +}; +</script> + +<template> + <div> + <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{ + $options.i18n.headerTitle + }}</label> + <gl-dropdown + data-testid="project-select-dropdown" + :text="selectedProjectName" + :header-text="$options.i18n.headerTitle" + block + menu-class="gl-w-full!" + :loading="initialLoading" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + debounce="250" + :placeholder="$options.i18n.searchPlaceholder" + /> + <gl-dropdown-item + v-for="project in projects" + v-show="!isFetching" + :key="project.id" + :name="project.name" + @click="selectProject(project.id)" + > + {{ project.namespacedName }} + </gl-dropdown-item> + <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> + <gl-loading-icon class="gl-mx-auto" /> + </gl-dropdown-text> + <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index ce267be6d45..61863bbe2a9 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -3,6 +3,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { GlButton, GlLoadingIcon }, + inject: ['canUpdate'], props: { title: { type: String, @@ -14,20 +15,41 @@ export default { required: false, default: false, }, + toggleHeader: { + type: Boolean, + required: false, + default: false, + }, + handleOffClick: { + type: Boolean, + required: false, + default: true, + }, }, - inject: ['canUpdate'], data() { return { edit: false, }; }, + computed: { + showHeader() { + if (!this.toggleHeader) { + return true; + } + + return !this.edit; + }, + }, destroyed() { window.removeEventListener('click', this.collapseWhenOffClick); }, methods: { collapseWhenOffClick({ target }) { if (!this.$el.contains(target)) { - this.collapse(); + this.$emit('off-click'); + if (this.handleOffClick) { + this.collapse(); + } } }, expand() { @@ -63,21 +85,26 @@ export default { <template> <div> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <header + v-show="showHeader" + class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-mb-3" + > <span class="gl-vertical-align-middle"> - <span data-testid="title">{{ title }}</span> + <slot name="title"> + <span data-testid="title">{{ title }}</span> + </slot> <gl-loading-icon v-if="loading" inline class="gl-ml-2" /> </span> <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900! js-sidebar-dropdown-toggle" + class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle" data-testid="edit-button" @click="toggle" > {{ __('Edit') }} </gl-button> - </div> + </header> <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 904ceaed1b3..4a664d5beef 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -18,16 +18,16 @@ export default { }; }, computed: { - ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }), + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), hasDueDate() { - return this.issue.dueDate != null; + return this.activeIssue.dueDate != null; }, parsedDueDate() { if (!this.hasDueDate) { return null; } - return parsePikadayDate(this.issue.dueDate); + return parsePikadayDate(this.activeIssue.dueDate); }, formattedDueDate() { if (!this.hasDueDate) { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue new file mode 100644 index 00000000000..d0e641daf5c --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue @@ -0,0 +1,171 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; +import { joinPaths } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + GlForm, + GlAlert, + GlButton, + GlFormGroup, + GlFormInput, + BoardEditableItem, + }, + directives: { + autofocusonshow, + }, + data() { + return { + title: '', + loading: false, + showChangesAlert: false, + }; + }, + computed: { + ...mapGetters({ issue: 'activeIssue' }), + pendingChangesStorageKey() { + return this.getPendingChangesKey(this.issue); + }, + projectPath() { + const referencePath = this.issue.referencePath || ''; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + validationState() { + return Boolean(this.title); + }, + }, + watch: { + issue: { + handler(updatedIssue, formerIssue) { + if (formerIssue?.title !== this.title) { + localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title); + } + + this.title = updatedIssue.title; + this.setPendingState(); + }, + immediate: true, + }, + }, + methods: { + ...mapActions(['setActiveIssueTitle']), + getPendingChangesKey(issue) { + if (!issue) { + return ''; + } + + return joinPaths( + window.location.pathname.slice(1), + String(issue.id), + 'issue-title-pending-changes', + ); + }, + async setPendingState() { + const pendingChanges = localStorage.getItem(this.pendingChangesStorageKey); + + if (pendingChanges) { + this.title = pendingChanges; + this.showChangesAlert = true; + await this.$nextTick(); + this.$refs.sidebarItem.expand(); + } else { + this.showChangesAlert = false; + } + }, + cancel() { + this.title = this.issue.title; + this.$refs.sidebarItem.collapse(); + this.showChangesAlert = false; + localStorage.removeItem(this.pendingChangesStorageKey); + }, + async setTitle() { + this.$refs.sidebarItem.collapse(); + + if (!this.title || this.title === this.issue.title) { + return; + } + + try { + this.loading = true; + await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath }); + localStorage.removeItem(this.pendingChangesStorageKey); + this.showChangesAlert = false; + } catch (e) { + this.title = this.issue.title; + createFlash({ message: this.$options.i18n.updateTitleError }); + } finally { + this.loading = false; + } + }, + handleOffClick() { + if (this.title !== this.issue.title) { + this.showChangesAlert = true; + localStorage.setItem(this.pendingChangesStorageKey, this.title); + } else { + this.$refs.sidebarItem.collapse(); + } + }, + }, + i18n: { + issueTitlePlaceholder: __('Issue title'), + submitButton: __('Save changes'), + cancelButton: __('Cancel'), + updateTitleError: __('An error occurred when updating the issue title'), + invalidFeedback: __('An issue title is required'), + reviewYourChanges: __('Changes to the title have not been saved'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + toggle-header + :loading="loading" + :handle-off-click="false" + @off-click="handleOffClick" + > + <template #title> + <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span> + </template> + <template #collapsed> + <span class="gl-text-gray-800">{{ issue.referencePath }}</span> + </template> + <template> + <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> + {{ $options.i18n.reviewYourChanges }} + </gl-alert> + <gl-form @submit.prevent="setTitle"> + <gl-form-group :invalid-feedback="$options.i18n.invalidFeedback" :state="validationState"> + <gl-form-input + v-model="title" + v-autofocusonshow + :placeholder="$options.i18n.issueTitlePlaceholder" + :state="validationState" + /> + </gl-form-group> + + <div class="gl-display-flex gl-w-full gl-justify-content-space-between gl-mt-5"> + <gl-button + variant="success" + size="small" + data-testid="submit-button" + :disabled="!title" + @click="setTitle" + > + {{ $options.i18n.submitButton }} + </gl-button> + + <gl-button size="small" data-testid="cancel-button" @click="cancel"> + {{ $options.i18n.cancelButton }} + </gl-button> + </div> + </gl-form> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 6a407bd6ba6..dcf769e6fe5 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -14,18 +14,18 @@ export default { LabelsSelect, GlLabel, }, + inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], data() { return { loading: false, }; }, - inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), selectedLabels() { const { labels = [] } = this.activeIssue; - return labels.map(label => ({ + return labels.map((label) => ({ ...label, id: getIdFromGraphQLId(label.id), })); @@ -33,7 +33,7 @@ export default { issueLabels() { const { labels = [] } = this.activeIssue; - return labels.map(label => ({ + return labels.map((label) => ({ ...label, scoped: isScopedLabel(label), })); @@ -46,10 +46,10 @@ export default { this.$refs.sidebarItem.collapse(); try { - const addLabelIds = payload.filter(label => label.set).map(label => label.id); + const addLabelIds = payload.filter((label) => label.set).map((label) => label.id); const removeLabelIds = this.selectedLabels - .filter(label => !payload.find(selected => selected.id === label.id)) - .map(label => label.id); + .filter((label) => !payload.find((selected) => selected.id === label.id)) + .map((label) => label.id); const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue index 78c3f8acc62..144a81f009b 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -50,7 +50,7 @@ export default { }, update(data) { const edges = data?.group?.milestones?.edges ?? []; - return edges.map(item => item.node); + return edges.map((item) => item.node); }, error() { createFlash({ message: this.$options.i18n.fetchMilestonesError }); @@ -58,20 +58,20 @@ export default { }, }, computed: { - ...mapGetters({ issue: 'activeIssue' }), + ...mapGetters(['activeIssue']), hasMilestone() { - return this.issue.milestone !== null; + return this.activeIssue.milestone !== null; }, groupFullPath() { - const { referencePath = '' } = this.issue; + const { referencePath = '' } = this.activeIssue; return referencePath.slice(0, referencePath.indexOf('/')); }, projectPath() { - const { referencePath = '' } = this.issue; + const { referencePath = '' } = this.activeIssue; return referencePath.slice(0, referencePath.indexOf('#')); }, dropdownText() { - return this.issue.milestone?.title ?? this.$options.i18n.noMilestone; + return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone; }, }, mounted() { @@ -120,7 +120,7 @@ export default { @close="edit = false" > <template v-if="hasMilestone" #collapsed> - <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong> + <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> </template> <template> <gl-dropdown @@ -133,7 +133,7 @@ export default { <gl-dropdown-item data-testid="no-milestone-item" :is-check-item="true" - :is-checked="!issue.milestone" + :is-checked="!activeIssue.milestone" @click="setMilestone(null)" > {{ $options.i18n.noMilestone }} @@ -145,7 +145,7 @@ export default { v-for="milestone in milestones" :key="milestone.id" :is-check-item="true" - :is-checked="issue.milestone && milestone.id === issue.milestone.id" + :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id" data-testid="milestone-item" @click="setMilestone(milestone.id)" > diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue index ed069cea630..4aa8d2f55e4 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -15,7 +15,7 @@ export default { ), }, updateSubscribedErrorMessage: s__( - 'IssueBoards|An error occurred while setting notifications status.', + 'IssueBoards|An error occurred while setting notifications status. Please try again.', ), }, components: { diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index 4e5a6609042..8d65f3240c8 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -42,13 +42,13 @@ export default { axios.patch(this.updateUrl, data).catch(() => { Flash(__('Failed to remove issue from board, please try again.')); - lists.forEach(list => { + lists.forEach((list) => { list.addIssue(issue); }); }); // Remove from the frontend store - lists.forEach(list => { + lists.forEach((list) => { list.removeIssue(issue); }); @@ -58,9 +58,11 @@ export default { * Build the default patch request. */ buildPatchRequest(issue, lists) { - const listLabelIds = lists.map(list => list.label.id); + const listLabelIds = lists.map((list) => list.label.id); - const labelIds = issue.labels.map(label => label.id).filter(id => !listLabelIds.includes(id)); + const labelIds = issue.labels + .map((label) => label.id) + .filter((id) => !listLabelIds.includes(id)); return { label_ids: labelIds, diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 1667dcc9f2e..94b35aadaf1 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -23,8 +23,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager { // Issue boards is slightly different, we handle all the requests async // instead or reloading the page, we just re-fire the list ajax requests this.isHandledAsync = true; - this.cantEdit = cantEdit.filter(i => typeof i === 'string'); - this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object'); + this.cantEdit = cantEdit.filter((i) => typeof i === 'string'); + this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object'); if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) { const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig); @@ -55,7 +55,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { const tokens = FilteredSearchContainer.container.querySelectorAll('.js-visual-token'); // Remove all the tokens as they will be replaced by the search manager - [].forEach.call(tokens, el => { + [].forEach.call(tokens, (el) => { el.parentNode.removeChild(el); }); @@ -75,7 +75,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { if (this.cantEdit.includes(tokenName)) return false; return ( this.cantEditWithValue.findIndex( - token => token.name === tokenName && token.value === tokenValue, + (token) => token.name === tokenName && token.value === tokenValue, ) === -1 ); } diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js index 9eaa0cd227d..c35dedde71b 100644 --- a/app/assets/javascripts/boards/filters/due_date_filters.js +++ b/app/assets/javascripts/boards/filters/due_date_filters.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import dateFormat from 'dateformat'; -Vue.filter('due-date', value => { +Vue.filter('due-date', (value) => { const date = new Date(value); return dateFormat(date, 'mmm d, yyyy', true); }); diff --git a/app/assets/javascripts/boards/graphql/board.mutation.graphql b/app/assets/javascripts/boards/graphql/board.mutation.graphql deleted file mode 100644 index ef2b81a7939..00000000000 --- a/app/assets/javascripts/boards/graphql/board.mutation.graphql +++ /dev/null @@ -1,11 +0,0 @@ -mutation UpdateBoard($id: ID!, $hideClosedList: Boolean, $hideBacklogList: Boolean) { - updateBoard( - input: { id: $id, hideClosedList: $hideClosedList, hideBacklogList: $hideBacklogList } - ) { - board { - id - hideClosedList - hideBacklogList - } - } -} diff --git a/app/assets/javascripts/boards/graphql/board_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql new file mode 100644 index 00000000000..b3ea79d6443 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql @@ -0,0 +1,9 @@ +mutation createBoard($input: CreateBoardInput!) { + createBoard(input: $input) { + board { + id + webPath + } + errors + } +} diff --git a/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql new file mode 100644 index 00000000000..d4b928749de --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql @@ -0,0 +1,7 @@ +mutation destroyBoard($id: BoardID!) { + destroyBoard(input: { id: $id }) { + board { + id + } + } +} diff --git a/app/assets/javascripts/boards/graphql/board_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql new file mode 100644 index 00000000000..3abe09079c7 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql @@ -0,0 +1,9 @@ +mutation UpdateBoard($input: UpdateBoardInput!) { + updateBoard(input: $input) { + board { + id + webPath + } + errors + } +} diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql new file mode 100644 index 00000000000..1afa6e48547 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getGroupProjects($fullPath: ID!, $search: String, $after: String) { + group(fullPath: $fullPath) { + projects(search: $search, after: $after, first: 100) { + nodes { + id + name + fullPath + nameWithNamespace + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql new file mode 100644 index 00000000000..62e6c1352a6 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql @@ -0,0 +1,8 @@ +mutation issueSetTitle($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + title + } + errors + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 64a4f246735..ef70a094f7c 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -55,7 +55,7 @@ export default () => { const $boardApp = document.getElementById('board-app'); // check for browser back and trigger a hard reload to circumvent browser caching. - window.addEventListener('pageshow', event => { + window.addEventListener('pageshow', (event) => { const isNavTypeBackForward = window.performance && window.performance.navigation.type === NavigationType.TYPE_BACK_FORWARD; @@ -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, @@ -117,16 +119,9 @@ export default () => { }, }, created() { - const endpoints = { - boardsEndpoint: this.boardsEndpoint, - recentBoardsEndpoint: this.recentBoardsEndpoint, - listsEndpoint: this.listsEndpoint, - bulkUpdatePath: this.bulkUpdatePath, + this.setInitialBoardData({ boardId: $boardApp.dataset.boardId, fullPath: $boardApp.dataset.fullPath, - }; - this.setInitialBoardData({ - ...endpoints, boardType: this.parent, disabled: this.disabled, boardConfig: { @@ -134,14 +129,23 @@ 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, }, }); - boardsStore.setEndpoints(endpoints); + boardsStore.setEndpoints({ + boardsEndpoint: this.boardsEndpoint, + recentBoardsEndpoint: this.recentBoardsEndpoint, + listsEndpoint: this.listsEndpoint, + bulkUpdatePath: this.bulkUpdatePath, + boardId: $boardApp.dataset.boardId, + fullPath: $boardApp.dataset.fullPath, + }); boardsStore.rootPath = this.boardsEndpoint; eventHub.$on('updateTokens', this.updateTokens); @@ -174,9 +178,9 @@ export default () => { initialBoardLoad() { boardsStore .all() - .then(res => res.data) - .then(lists => { - lists.forEach(list => boardsStore.addList(list)); + .then((res) => res.data) + .then((lists) => { + lists.forEach((list) => boardsStore.addList(list)); this.loading = false; }) .catch(() => { @@ -194,8 +198,8 @@ export default () => { setEpicFetchingState(newIssue, true); boardsStore .getIssueInfo(sidebarInfoEndpoint) - .then(res => res.data) - .then(data => { + .then((res) => res.data) + .then((data) => { const { subscribed, totalTimeSpent, @@ -305,7 +309,7 @@ export default () => { if (!this.store) { return true; } - return !this.store.lists.filter(list => !list.preset).length; + return !this.store.lists.filter((list) => !list.preset).length; }, }, methods: { @@ -335,7 +339,7 @@ export default () => { } mountMultipleBoardsSwitcher({ - boardsEndpoint: $boardApp.dataset.boardsEndpoint, - recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, + fullPath: $boardApp.dataset.fullPath, + rootPath: $boardApp.dataset.boardsEndpoint, }); }; diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index f02c92e4230..a95d749d71c 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -24,7 +24,7 @@ export function getBoardSortableDefaultOptions(obj) { onEnd: sortableEnd, }; - Object.keys(obj).forEach(key => { + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); return defaultSortOptions; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 822e6d62ab3..1e77326ba9c 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -70,7 +70,7 @@ class ListIssue { } getLists() { - return boardsStore.state.lists.filter(list => list.findIssue(this.id)); + return boardsStore.state.lists.filter((list) => list.findIssue(this.id)); } updateData(newData) { diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 09f5d5b4dd8..be02ac7b889 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -35,7 +35,7 @@ class List { constructor(obj) { this.id = obj.id; this.position = obj.position; - this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title; + this.title = obj.title; this.type = obj.list_type || obj.listType; const typeInfo = this.getTypeInfo(this.type); @@ -134,7 +134,7 @@ class List { updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) { boardsStore .moveMultipleIssues({ - ids: issues.map(issue => issue.id), + ids: issues.map((issue) => issue.id), fromListId: listFrom.id, toListId: this.id, moveBeforeId, diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index df65ebb7526..738c8fb927e 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -export default (endpoints = {}) => { +export default (params = {}) => { const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); return new Vue({ el: boardsSwitcherElement, @@ -18,6 +18,10 @@ export default (endpoints = {}) => { BoardsSelector, }, apolloProvider, + provide: { + fullPath: params.fullPath, + rootPath: params.rootPath, + }, data() { const { dataset } = boardsSwitcherElement; @@ -35,9 +39,6 @@ export default (endpoints = {}) => { return { boardsSelectorProps }; }, - provide: { - endpoints, - }, render(createElement) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 59b97eba9fe..1d34f21798a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -12,6 +12,8 @@ import { fullBoardId, formatListsPageInfo, formatIssue, + formatIssueInput, + updateListPosition, } from '../boards_util'; import createFlash from '~/flash'; import { __ } from '~/locale'; @@ -27,6 +29,8 @@ import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql'; import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; +import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql'; +import groupProjectsQuery from '../graphql/group_projects.query.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -78,8 +82,7 @@ export default { }, fetchLists: ({ commit, state, dispatch }) => { - const { endpoints, boardType, filterParams } = state; - const { fullPath, boardId } = endpoints; + const { boardType, filterParams, fullPath, boardId } = state; const variables = { fullPath, @@ -98,7 +101,7 @@ export default { const { lists, hideBacklogList } = data[boardType]?.board; commit(types.RECEIVE_BOARD_LISTS_SUCCESS, formatBoardLists(lists)); // Backlog list needs to be created if it doesn't exist and it's not hidden - if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) { + if (!lists.nodes.find((l) => l.listType === ListType.backlog) && !hideBacklogList) { dispatch('createList', { backlog: true }); } }) @@ -106,7 +109,7 @@ export default { }, createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { - const { boardId } = state.endpoints; + const { boardId } = state; gqlClient .mutate({ @@ -131,12 +134,11 @@ export default { }, addList: ({ commit }, list) => { - commit(types.RECEIVE_ADD_LIST_SUCCESS, list); + commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); }, fetchLabels: ({ state, commit }, searchTerm) => { - const { endpoints, boardType } = state; - const { fullPath } = endpoints; + const { fullPath, boardType } = state; const variables = { fullPath, @@ -214,11 +216,17 @@ export default { listId, }, }) - .then(({ data: { destroyBoardList: { errors } } }) => { - if (errors.length > 0) { - commit(types.REMOVE_LIST_FAILURE, listsBackup); - } - }) + .then( + ({ + data: { + destroyBoardList: { errors }, + }, + }) => { + if (errors.length > 0) { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + } + }, + ) .catch(() => { commit(types.REMOVE_LIST_FAILURE, listsBackup); }); @@ -227,8 +235,7 @@ export default { fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); - const { endpoints, boardType, filterParams } = state; - const { fullPath, boardId } = endpoints; + const { fullPath, boardId, boardType, filterParams } = state; const variables = { fullPath, @@ -271,7 +278,7 @@ export default { const originalIndex = fromList.indexOf(Number(issueId)); commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); - const { boardId } = state.endpoints; + const { boardId } = state; const [fullProjectPath] = issuePath.split(/[#]/); gqlClient @@ -356,10 +363,13 @@ export default { }, createNewIssue: ({ commit, state }, issueInput) => { - const input = issueInput; - const { boardType, endpoints } = state; + const { boardConfig } = state; + + const input = formatIssueInput(issueInput, boardConfig); + + const { boardType, fullPath } = state; if (boardType === BoardType.project) { - input.projectPath = endpoints.fullPath; + input.projectPath = fullPath; } return gqlClient @@ -387,7 +397,7 @@ export default { commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 }); dispatch('createNewIssue', issueInput) - .then(res => { + .then((res) => { commit(types.ADD_ISSUE_TO_LIST, { list, issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }), @@ -469,6 +479,61 @@ export default { }); }, + setActiveIssueTitle: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetTitleMutation, + variables: { + input: { + iid: String(activeIssue.iid), + projectPath: input.projectPath, + title: input.title, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'title', + value: data.updateIssue.issue.title, + }); + }, + + fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => { + commit(types.REQUEST_GROUP_PROJECTS, fetchNext); + + const { fullPath } = state; + + const variables = { + fullPath, + search: search !== '' ? search : undefined, + after: fetchNext ? state.groupProjectsFlags.pageInfo.endCursor : undefined, + }; + + return gqlClient + .query({ + query: groupProjectsQuery, + variables, + }) + .then(({ data }) => { + const { projects } = data.group; + commit(types.RECEIVE_GROUP_PROJECTS_SUCCESS, { + projects: projects.nodes, + pageInfo: projects.pageInfo, + fetchNext, + }); + }) + .catch(() => commit(types.RECEIVE_GROUP_PROJECTS_FAILURE)); + }, + + setSelectedProject: ({ commit }, project) => { + commit(types.SET_SELECTED_PROJECT, project); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 36702b6ca5f..f59530ddf8f 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -101,7 +101,7 @@ const boardsStore = { }, new(listObj) { const list = this.addList(listObj); - const backlogList = this.findList('type', 'backlog', 'backlog'); + const backlogList = this.findList('type', 'backlog'); list .save() @@ -124,7 +124,7 @@ const boardsStore = { }, findIssueLabel(issue, findLabel) { - return issue.labels.find(label => label.id === findLabel.id); + return issue.labels.find((label) => label.id === findLabel.id); }, goToNextPage(list) { @@ -182,15 +182,15 @@ const boardsStore = { } }, findListIssue(list, id) { - return list.issues.find(issue => issue.id === id); + return list.issues.find((issue) => issue.id === id); }, - removeList(id, type = 'blank') { - const list = this.findList('id', id, type); + removeList(id) { + const list = this.findList('id', id); if (!list) return; - this.state.lists = this.state.lists.filter(list => list.id !== id); + this.state.lists = this.state.lists.filter((list) => list.id !== id); }, moveList(listFrom, orderLists) { orderLists.forEach((id, i) => { @@ -205,7 +205,7 @@ const boardsStore = { let moveBeforeId = null; let moveAfterId = null; - const listHasIssues = issues.every(issue => list.findIssue(issue.id)); + const listHasIssues = issues.every((issue) => list.findIssue(issue.id)); if (!listHasIssues) { if (newIndex !== undefined) { @@ -223,21 +223,21 @@ const boardsStore = { } if (list.label) { - issues.forEach(issue => issue.addLabel(list.label)); + issues.forEach((issue) => issue.addLabel(list.label)); } if (list.assignee) { if (listFrom && listFrom.type === 'assignee') { - issues.forEach(issue => issue.removeAssignee(listFrom.assignee)); + issues.forEach((issue) => issue.removeAssignee(listFrom.assignee)); } - issues.forEach(issue => issue.addAssignee(list.assignee)); + issues.forEach((issue) => issue.addAssignee(list.assignee)); } if (IS_EE && list.milestone) { if (listFrom && listFrom.type === 'milestone') { - issues.forEach(issue => issue.removeMilestone(listFrom.milestone)); + issues.forEach((issue) => issue.removeMilestone(listFrom.milestone)); } - issues.forEach(issue => issue.addMilestone(list.milestone)); + issues.forEach((issue) => issue.addMilestone(list.milestone)); } if (listFrom) { @@ -249,7 +249,7 @@ const boardsStore = { }, removeListIssues(list, removeIssue) { - list.issues = list.issues.filter(issue => { + list.issues = list.issues.filter((issue) => { const matchesRemove = removeIssue.id === issue.id; if (matchesRemove) { @@ -261,9 +261,9 @@ const boardsStore = { }); }, removeListMultipleIssues(list, removeIssues) { - const ids = removeIssues.map(issue => issue.id); + const ids = removeIssues.map((issue) => issue.id); - list.issues = list.issues.filter(issue => { + list.issues = list.issues.filter((issue) => { const matchesRemove = ids.includes(issue.id); if (matchesRemove) { @@ -289,9 +289,9 @@ const boardsStore = { }, moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) { - const issueTo = issues.map(issue => listTo.findIssue(issue.id)); - const issueLists = issues.map(issue => issue.getLists()).flat(); - const listLabels = issueLists.map(list => list.label); + const issueTo = issues.map((issue) => listTo.findIssue(issue.id)); + const issueLists = issues.map((issue) => issue.getLists()).flat(); + const listLabels = issueLists.map((list) => list.label); const hasMoveableIssues = issueTo.filter(Boolean).length > 0; if (!hasMoveableIssues) { @@ -299,30 +299,30 @@ const boardsStore = { if ( listTo.type === ListType.assignee && listFrom.type === ListType.assignee && - issues.some(issue => issue.findAssignee(listTo.assignee)) + issues.some((issue) => issue.findAssignee(listTo.assignee)) ) { - const targetIssues = issues.map(issue => listTo.findIssue(issue.id)); - targetIssues.forEach(targetIssue => targetIssue.removeAssignee(listFrom.assignee)); + const targetIssues = issues.map((issue) => listTo.findIssue(issue.id)); + targetIssues.forEach((targetIssue) => targetIssue.removeAssignee(listFrom.assignee)); } else if (listTo.type === 'milestone') { - const currentMilestones = issues.map(issue => issue.milestone); + const currentMilestones = issues.map((issue) => issue.milestone); const currentLists = this.state.lists - .filter(list => list.type === 'milestone' && list.id !== listTo.id) - .filter(list => - list.issues.some(listIssue => issues.some(issue => listIssue.id === issue.id)), + .filter((list) => list.type === 'milestone' && list.id !== listTo.id) + .filter((list) => + list.issues.some((listIssue) => issues.some((issue) => listIssue.id === issue.id)), ); - issues.forEach(issue => { - currentMilestones.forEach(milestone => { + issues.forEach((issue) => { + currentMilestones.forEach((milestone) => { issue.removeMilestone(milestone); }); }); - issues.forEach(issue => { + issues.forEach((issue) => { issue.addMilestone(listTo.milestone); }); - currentLists.forEach(currentList => { - issues.forEach(issue => { + currentLists.forEach((currentList) => { + issues.forEach((issue) => { currentList.removeIssue(issue); }); }); @@ -334,36 +334,36 @@ const boardsStore = { } } else { listTo.updateMultipleIssues(issues, listFrom); - issues.forEach(issue => { + issues.forEach((issue) => { issue.removeLabel(listFrom.label); }); } if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) { - issueLists.forEach(list => { - issues.forEach(issue => { + issueLists.forEach((list) => { + issues.forEach((issue) => { list.removeIssue(issue); }); }); - issues.forEach(issue => { + issues.forEach((issue) => { issue.removeLabels(listLabels); }); } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) { - issues.forEach(issue => { + issues.forEach((issue) => { issue.removeAssignee(listFrom.assignee); }); - issueLists.forEach(list => { - issues.forEach(issue => { + issueLists.forEach((list) => { + issues.forEach((issue) => { list.removeIssue(issue); }); }); } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) { - issues.forEach(issue => { + issues.forEach((issue) => { issue.removeMilestone(listFrom.milestone); }); - issueLists.forEach(list => { - issues.forEach(issue => { + issueLists.forEach((list) => { + issues.forEach((issue) => { list.removeIssue(issue); }); }); @@ -380,8 +380,8 @@ const boardsStore = { if (issues.length === 1) return true; // Create list of ids for issues involved. - const listIssueIds = list.issues.map(issue => issue.id); - const movedIssueIds = issues.map(issue => issue.id); + const listIssueIds = list.issues.map((issue) => issue.id); + const movedIssueIds = issues.map((issue) => issue.id); // Check if moved issue IDs is sub-array // of source list issue IDs (i.e. contiguous selection). @@ -391,7 +391,7 @@ const boardsStore = { moveIssueToList(listFrom, listTo, issue, newIndex) { const issueTo = listTo.findIssue(issue.id); const issueLists = issue.getLists(); - const listLabels = issueLists.map(listIssue => listIssue.label); + const listLabels = issueLists.map((listIssue) => listIssue.label); if (!issueTo) { // Check if target list assignee is already present in this issue @@ -405,12 +405,12 @@ const boardsStore = { } else if (listTo.type === 'milestone') { const currentMilestone = issue.milestone; const currentLists = this.state.lists - .filter(list => list.type === 'milestone' && list.id !== listTo.id) - .filter(list => list.issues.some(listIssue => issue.id === listIssue.id)); + .filter((list) => list.type === 'milestone' && list.id !== listTo.id) + .filter((list) => list.issues.some((listIssue) => issue.id === listIssue.id)); issue.removeMilestone(currentMilestone); issue.addMilestone(listTo.milestone); - currentLists.forEach(currentList => currentList.removeIssue(issue)); + currentLists.forEach((currentList) => currentList.removeIssue(issue)); listTo.addIssue(issue, listFrom, newIndex); } else { // Add to new lists issues if it doesn't already exist @@ -422,7 +422,7 @@ const boardsStore = { } if (listTo.type === 'closed' && listFrom.type !== 'backlog') { - issueLists.forEach(list => { + issueLists.forEach((list) => { list.removeIssue(issue); }); issue.removeLabels(listLabels); @@ -461,18 +461,11 @@ const boardsStore = { moveAfterId: afterId, }); }, - findList(key, val, type = 'label') { - const filteredList = this.state.lists.filter(list => { - const byType = type - ? list.type === type || list.type === 'assignee' || list.type === 'milestone' - : true; - - return list[key] === val && byType; - }); - return filteredList[0]; + findList(key, val) { + return this.state.lists.find((list) => list[key] === val); }, findListByLabelId(id) { - return this.state.lists.find(list => list.type === 'label' && list.label.id === id); + return this.state.lists.find((list) => list.type === 'label' && list.label.id === id); }, toggleFilter(filter) { @@ -589,8 +582,8 @@ const boardsStore = { } return this.createList(entity.id, entityType) - .then(res => res.data) - .then(data => { + .then((res) => res.data) + .then((data) => { list.id = data.id; list.type = data.list_type; list.position = data.position; @@ -607,7 +600,7 @@ const boardsStore = { }; if (list.label && data.label_name) { - data.label_name = data.label_name.filter(label => label !== list.label.title); + data.label_name = data.label_name.filter((label) => label !== list.label.title); } if (emptyIssues) { @@ -615,8 +608,8 @@ const boardsStore = { } return this.getIssuesForList(list.id, data) - .then(res => res.data) - .then(data => { + .then((res) => res.data) + .then((data) => { list.loading = false; list.issuesSize = data.size; @@ -624,7 +617,7 @@ const boardsStore = { list.issues = []; } - data.issues.forEach(issueObj => { + data.issues.forEach((issueObj) => { list.addIssue(new ListIssue(issueObj)); }); @@ -634,7 +627,7 @@ const boardsStore = { getIssuesForList(id, filter = {}) { const data = { id }; - Object.keys(filter).forEach(key => { + Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); @@ -670,13 +663,13 @@ const boardsStore = { }, moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) { - oldIndicies.reverse().forEach(index => { + oldIndicies.reverse().forEach((index) => { list.issues.splice(index, 1); }); list.issues.splice(newIndex, 0, ...issues); return this.moveMultipleIssues({ - ids: issues.map(issue => issue.id), + ids: issues.map((issue) => issue.id), fromListId: null, toListId: null, moveBeforeId, @@ -703,8 +696,8 @@ const boardsStore = { } return this.newIssue(list.id, issue) - .then(res => res.data) - .then(data => list.onNewIssueResponse(issue, data)); + .then((res) => res.data) + .then((data) => list.onNewIssueResponse(issue, data)); }, getBacklog(data) { @@ -717,7 +710,7 @@ const boardsStore = { }, removeIssueLabel(issue, removeLabel) { if (removeLabel) { - issue.labels = issue.labels.filter(label => removeLabel.id !== label.id); + issue.labels = issue.labels.filter((label) => removeLabel.id !== label.id); } }, @@ -753,16 +746,12 @@ const boardsStore = { return axios.get(this.state.endpoints.recentBoardsEndpoint); }, - deleteBoard({ id }) { - return axios.delete(this.generateBoardsPath(id)); - }, - setCurrentBoard(board) { this.state.currentBoard = board; }, toggleMultiSelect(issue) { - const selectedIssueIds = this.multiSelect.list.map(issue => issue.id); + const selectedIssueIds = this.multiSelect.list.map((issue) => issue.id); const index = selectedIssueIds.indexOf(issue.id); if (index === -1) { @@ -777,12 +766,12 @@ const boardsStore = { }, removeIssueAssignee(issue, removeAssignee) { if (removeAssignee) { - issue.assignees = issue.assignees.filter(assignee => assignee.id !== removeAssignee.id); + issue.assignees = issue.assignees.filter((assignee) => assignee.id !== removeAssignee.id); } }, findIssueAssignee(issue, findAssignee) { - return issue.assignees.find(assignee => assignee.id === findAssignee.id); + return issue.assignees.find((assignee) => assignee.id === findAssignee.id); }, clearMultiSelect() { @@ -837,11 +826,11 @@ const boardsStore = { } if (obj.labels) { - issue.labels = obj.labels.map(label => new ListLabel(label)); + issue.labels = obj.labels.map((label) => new ListLabel(label)); } if (obj.assignees) { - issue.assignees = obj.assignees.map(a => new ListAssignee(a)); + issue.assignees = obj.assignees.map((a) => new ListAssignee(a)); } }, addIssueLabel(issue, label) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index ca6887b6f45..d72b5c6fb8e 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -2,18 +2,18 @@ import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { - isSidebarOpen: state => state.activeId !== inactiveId, + isSidebarOpen: (state) => state.activeId !== inactiveId, isSwimlanesOn: () => false, - getIssueById: state => id => { + getIssueById: (state) => (id) => { return state.issues[id] || {}; }, - getIssuesByList: (state, getters) => listId => { + getIssuesByList: (state, getters) => (listId) => { const listIssueIds = state.issuesByListId[listId] || []; - return listIssueIds.map(id => getters.getIssueById(id)); + return listIssueIds.map((id) => getters.getIssueById(id)); }, - activeIssue: state => { + activeIssue: (state) => { return state.issues[state.activeId] || {}; }, @@ -22,12 +22,12 @@ export default { return referencePath.slice(0, referencePath.indexOf('#')); }, - getListByLabelId: state => labelId => { - return find(state.boardLists, l => l.label?.id === labelId); + getListByLabelId: (state) => (labelId) => { + return find(state.boardLists, (l) => l.label?.id === labelId); }, - getListByTitle: state => title => { - return find(state.boardLists, l => l.title === title); + getListByTitle: (state) => (title) => { + return find(state.boardLists, (l) => l.title === title); }, shouldUseGraphQL: () => { diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js index b7228bf7bf5..8a8fa61361c 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -40,7 +40,7 @@ class ModalStore { toggleAll() { const select = this.selectedCount() !== this.store.issues.length; - this.store.issues.forEach(issue => { + this.store.issues.forEach((issue) => { const issueUpdate = issue; if (issueUpdate.selected !== select) { @@ -56,7 +56,7 @@ class ModalStore { } getSelectedIssues() { - return this.store.selectedIssues.filter(issue => issue.selected); + return this.store.selectedIssues.filter((issue) => issue.selected); } addSelectedIssue(issue) { @@ -70,13 +70,13 @@ class ModalStore { removeSelectedIssue(issue, forcePurge = false) { if (this.store.activeTab === 'all' || forcePurge) { this.store.selectedIssues = this.store.selectedIssues.filter( - fIssue => fIssue.id !== issue.id, + (fIssue) => fIssue.id !== issue.id, ); } } purgeUnselectedIssues() { - this.store.selectedIssues.forEach(issue => { + this.store.selectedIssues.forEach((issue) => { if (!issue.selected) { this.removeSelectedIssue(issue, true); } @@ -88,7 +88,7 @@ class ModalStore { } findSelectedIssue(issue) { - return this.store.selectedIssues.filter(filteredIssue => filteredIssue.id === issue.id)[0]; + return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0]; } } diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 2b2c2bee51c..4697f39498a 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -36,3 +36,7 @@ export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; +export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; +export const RECEIVE_GROUP_PROJECTS_SUCCESS = 'RECEIVE_GROUP_PROJECTS_SUCCESS'; +export const RECEIVE_GROUP_PROJECTS_FAILURE = 'RECEIVE_GROUP_PROJECTS_FAILURE'; +export const SET_SELECTED_PROJECT = 'SET_SELECTED_PROJECT'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 8c4e514710f..6c79b22d308 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -32,8 +32,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardConfig, ...endpoints } = data; - state.endpoints = endpoints; + const { boardType, disabled, boardId, fullPath, boardConfig } = data; + state.boardId = boardId; + state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; state.boardConfig = boardConfig; @@ -43,7 +44,7 @@ export default { state.boardLists = lists; }, - [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => { + [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: (state) => { state.error = s__( 'Boards|An error occurred while fetching the board lists. Please reload the page.', ); @@ -58,15 +59,15 @@ export default { state.filterParams = filterParams; }, - [mutationTypes.CREATE_LIST_FAILURE]: state => { + [mutationTypes.CREATE_LIST_FAILURE]: (state) => { state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, - [mutationTypes.RECEIVE_LABELS_FAILURE]: state => { + [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => { state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); }, - [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: state => { + [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => { state.error = s__('Boards|An error occurred while generating lists. Please reload the page.'); }, @@ -128,8 +129,8 @@ export default { Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RESET_ISSUES]: state => { - Object.keys(state.issuesByListId).forEach(listId => { + [mutationTypes.RESET_ISSUES]: (state) => { + Object.keys(state.issuesByListId).forEach((listId) => { Vue.set(state.issuesByListId, listId, []); }); }, @@ -205,7 +206,7 @@ export default { notImplemented(); }, - [mutationTypes.CREATE_ISSUE_FAILURE]: state => { + [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => { state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); }, @@ -236,4 +237,25 @@ export default { [mutationTypes.TOGGLE_EMPTY_STATE]: () => { notImplemented(); }, + + [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => { + Vue.set(state, 'groupProjectsFlags', { + [fetchNext ? 'isLoadingMore' : 'isLoading']: true, + pageInfo: state.groupProjectsFlags.pageInfo, + }); + }, + + [mutationTypes.RECEIVE_GROUP_PROJECTS_SUCCESS]: (state, { projects, pageInfo, fetchNext }) => { + Vue.set(state, 'groupProjects', fetchNext ? [...state.groupProjects, ...projects] : projects); + Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false, pageInfo }); + }, + + [mutationTypes.RECEIVE_GROUP_PROJECTS_FAILURE]: (state) => { + state.error = s__('Boards|An error occurred while fetching group projects. Please try again.'); + Vue.set(state, 'groupProjectsFlags', { isLoading: false, isLoadingMore: false }); + }, + + [mutationTypes.SET_SELECTED_PROJECT]: (state, project) => { + state.selectedProject = project; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 573e98e56e0..aba7da373cf 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,7 +1,6 @@ import { inactiveId } from '~/boards/constants'; export default () => ({ - endpoints: {}, boardType: null, disabled: false, isShowingLabels: true, @@ -15,6 +14,13 @@ export default () => ({ issues: {}, filterParams: {}, boardConfig: {}, + groupProjects: [], + groupProjectsFlags: { + isLoading: false, + isLoadingMore: false, + pageInfo: {}, + }, + selectedProject: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, |