diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 04:45:44 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-19 04:45:44 +0300 |
commit | 85dc423f7090da0a52c73eb66faf22ddb20efff9 (patch) | |
tree | 9160f299afd8c80c038f08e1545be119f5e3f1e1 /app/assets/javascripts/boards | |
parent | 15c2c8c66dbe422588e5411eee7e68f1fa440bb8 (diff) |
Add latest changes from gitlab-org/gitlab@13-4-stable-ee
Diffstat (limited to 'app/assets/javascripts/boards')
46 files changed, 1084 insertions, 510 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 384a386d69c..5c8df94ca90 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,28 +1,72 @@ +import { sortBy } from 'lodash'; import ListIssue from 'ee_else_ce/boards/models/issue'; +import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export function getMilestone() { return null; } +export function formatIssue(issue) { + return new ListIssue({ + ...issue, + labels: issue.labels?.nodes || [], + assignees: issue.assignees?.nodes || [], + }); +} + export function formatListIssues(listIssues) { - return listIssues.nodes.reduce((map, list) => { + const issues = {}; + + const listData = listIssues.nodes.reduce((map, list) => { + const sortedIssues = sortBy(list.issues.nodes, 'relativePosition'); return { ...map, - [list.id]: list.issues.nodes.map( - i => - new ListIssue({ - ...i, - id: getIdFromGraphQLId(i.id), - labels: i.labels?.nodes || [], - assignees: i.assignees?.nodes || [], - }), - ), + [list.id]: sortedIssues.map(i => { + const id = getIdFromGraphQLId(i.id); + + const listIssue = new ListIssue({ + ...i, + id, + labels: i.labels?.nodes || [], + assignees: i.assignees?.nodes || [], + }); + + issues[id] = listIssue; + + return id; + }), }; }, {}); + + return { listData, issues }; +} + +export function fullBoardId(boardId) { + return `gid://gitlab/Board/${boardId}`; +} + +export function moveIssueListHelper(issue, fromList, toList) { + if (toList.type === ListType.label) { + issue.addLabel(toList.label); + } + if (fromList && fromList.type === ListType.label) { + issue.removeLabel(fromList.label); + } + + if (toList.type === ListType.assignee) { + issue.addAssignee(toList.assignee); + } + if (fromList && fromList.type === ListType.assignee) { + issue.removeAssignee(fromList.assignee); + } + + return issue; } export default { getMilestone, + formatIssue, formatListIssues, + fullBoardId, }; diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue index afdf0290e8e..55e3e4a6329 100644 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ b/app/assets/javascripts/boards/components/board_blank_state.vue @@ -1,10 +1,14 @@ <script> +import { GlButton } from '@gitlab/ui'; import Cookies from 'js-cookie'; import { __ } from '~/locale'; import ListLabel from '~/boards/models/label'; import boardsStore from '../stores/boards_store'; export default { + components: { + GlButton, + }, data() { return { predefinedLabels: [ @@ -84,15 +88,17 @@ export default { ) }} </p> - <button - class="btn btn-success btn-inverted btn-block" - type="button" + <gl-button + category="secondary" + variant="success" + block="block" + class="gl-mb-0" @click.stop="addDefaultLists" > {{ s__('BoardBlankState|Add default lists') }} - </button> - <button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState"> + </gl-button> + <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState"> {{ s__("BoardBlankState|Nevermind, I'll use my own") }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 246d3b9dcd1..31050eef83d 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/require-default-prop */ -import IssueCardInner from './issue_card_inner.vue'; +import BoardCardLayout from './board_card_layout.vue'; import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; import boardsStore from '../stores/boards_store'; @@ -8,7 +7,7 @@ import boardsStore from '../stores/boards_store'; export default { name: 'BoardsIssueCard', components: { - IssueCardInner, + BoardCardLayout, }, props: { list: { @@ -21,80 +20,29 @@ export default { default: () => ({}), required: false, }, - issueLinkBase: { - type: String, - default: '', - required: false, - }, - disabled: { - type: Boolean, - default: false, - required: false, - }, - index: { - type: Number, - default: 0, - required: false, - }, - rootPath: { - type: String, - default: '', - required: false, - }, - groupId: { - type: Number, - required: false, - }, - }, - data() { - return { - showDetail: false, - detailIssue: boardsStore.detail, - multiSelect: boardsStore.multiSelect, - }; - }, - computed: { - issueDetailVisible() { - return this.detailIssue.issue && this.detailIssue.issue.id === this.issue.id; - }, - multiSelectVisible() { - return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; - }, - canMultiSelect() { - return gon.features && gon.features.multiSelectBoard; - }, }, methods: { - mouseDown() { - this.showDetail = true; + // These are methods instead of computed's, because boardsStore is not reactive. + isActive() { + return this.getActiveId() === this.issue.id; }, - mouseMove() { - this.showDetail = false; + getActiveId() { + return boardsStore.detail?.issue?.id; }, - showIssue(e) { - if (e.target.classList.contains('js-no-trigger')) return; - + showIssue({ isMultiSelect }) { // If no issues are opened, close all sidebars first - if (!boardsStore.detail?.issue?.id) { + if (!this.getActiveId()) { sidebarEventHub.$emit('sidebar.closeAll'); } + if (this.isActive()) { + eventHub.$emit('clearDetailIssue', isMultiSelect); - // If CMD or CTRL is clicked - const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); - - if (this.showDetail || isMultiSelect) { - this.showDetail = false; - - if (boardsStore.detail.issue && boardsStore.detail.issue.id === this.issue.id) { - eventHub.$emit('clearDetailIssue', isMultiSelect); - - if (isMultiSelect) { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - } - } else { + if (isMultiSelect) { eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - boardsStore.setListDetail(this.list); } + } else { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + boardsStore.setListDetail(this.list); } }, }, @@ -102,28 +50,12 @@ export default { </script> <template> - <li - :class="{ - 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && issue.id, - 'is-disabled': disabled || !issue.id, - 'is-active': issueDetailVisible, - }" - :index="index" - :data-issue-id="issue.id" + <board-card-layout data-qa-selector="board_card" - class="board-card p-3 rounded" - @mousedown="mouseDown" - @mousemove="mouseMove" - @mouseup="showIssue($event)" - > - <issue-card-inner - :list="list" - :issue="issue" - :issue-link-base="issueLinkBase" - :group-id="groupId" - :root-path="rootPath" - :update-filters="true" - /> - </li> + :issue="issue" + :list="list" + :is-active="isActive()" + v-bind="$attrs" + @show="showIssue" + /> </template> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue new file mode 100644 index 00000000000..072dd87861a --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -0,0 +1,93 @@ +<script> +import IssueCardInner from './issue_card_inner.vue'; +import boardsStore from '../stores/boards_store'; + +export default { + name: 'BoardsIssueCard', + components: { + IssueCardInner, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + issue: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + index: { + type: Number, + default: 0, + required: false, + }, + isActive: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + showDetail: false, + multiSelect: boardsStore.multiSelect, + }; + }, + computed: { + multiSelectVisible() { + return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; + }, + canMultiSelect() { + return gon.features && gon.features.multiSelectBoard; + }, + }, + methods: { + mouseDown() { + this.showDetail = true; + }, + mouseMove() { + this.showDetail = false; + }, + showIssue(e) { + // Don't do anything if this happened on a no trigger element + if (e.target.classList.contains('js-no-trigger')) return; + + const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); + + if (this.showDetail || isMultiSelect) { + this.showDetail = false; + this.$emit('show', { event: e, isMultiSelect }); + } + }, + }, +}; +</script> + +<template> + <li + :class="{ + 'multi-select': multiSelectVisible, + 'user-can-drag': !disabled && issue.id, + 'is-disabled': disabled || !issue.id, + 'is-active': isActive, + }" + :index="index" + :data-issue-id="issue.id" + :data-issue-iid="issue.iid" + :data-issue-path="issue.referencePath" + data-testid="board_card" + class="board-card p-3 rounded" + @mousedown="mouseDown" + @mousemove="mouseMove" + @mouseup="showIssue($event)" + > + <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> + </li> +</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index dae24338e45..6d216911798 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,9 +1,11 @@ <script> +import { mapGetters, mapActions } from 'vuex'; import Sortable from 'sortablejs'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardBlankState from './board_blank_state.vue'; import BoardList from './board_list.vue'; import boardsStore from '../stores/boards_store'; @@ -21,7 +23,7 @@ export default { directives: { Tooltip, }, - mixins: [isWipLimitsOn], + mixins: [isWipLimitsOn, glFeatureFlagMixin()], props: { list: { type: Object, @@ -32,27 +34,15 @@ export default { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, canAdminList: { type: Boolean, required: false, default: false, }, - groupId: { - type: Number, - required: false, - default: null, + }, + inject: { + boardId: { + type: String, }, }, data() { @@ -62,6 +52,7 @@ export default { }; }, computed: { + ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; }, @@ -69,19 +60,36 @@ export default { // eslint-disable-next-line @gitlab/require-i18n-strings return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; }, + listIssues() { + if (!this.glFeatures.graphqlBoardLists) { + return this.list.issues; + } + return this.getIssues(this.list.id); + }, + shouldFetchIssues() { + return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank; + }, }, watch: { filter: { handler() { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); + if (this.shouldFetchIssues) { + this.fetchIssuesForList(this.list.id); + } else { + this.list.page = 1; + this.list.getIssues(true).catch(() => { + // TODO: handle request error + }); + } }, deep: true, }, }, mounted() { + if (this.shouldFetchIssues) { + this.fetchIssuesForList(this.list.id); + } + const instance = this; const sortableOptions = getBoardSortableDefaultOptions({ @@ -108,6 +116,7 @@ export default { Sortable.create(this.$el.parentNode, sortableOptions); }, methods: { + ...mapActions(['fetchIssuesForList']), showListNewIssueForm(listId) { eventHub.$emit('showForm', listId); }, @@ -130,22 +139,14 @@ export default { <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-id="boardId" - /> + <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> <board-list v-if="showBoardListAndBoardInfo" ref="board-list" :disabled="disabled" - :group-id="groupId || null" - :issue-link-base="issueLinkBase" - :issues="list.issues" + :issues="listIssues" :list="list" :loading="list.loading" - :root-path="rootPath" /> <board-blank-state v-if="canAdminList && list.id === 'blank'" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index c42295792f1..c7b3da0e672 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,13 +1,15 @@ <script> -import { mapState } from 'vuex'; +import { mapState, mapGetters, mapActions } from 'vuex'; import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; -import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; +import { GlAlert } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { BoardColumn, - EpicsSwimlanes, + BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), + EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), + GlAlert, }, mixins: [glFeatureFlagMixin()], props: { @@ -19,66 +21,58 @@ export default { type: Boolean, required: true, }, - groupId: { - type: Number, - required: false, - default: null, - }, disabled: { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - boardId: { - type: String, - required: true, - }, }, computed: { - ...mapState(['isShowingEpicsSwimlanes', 'boardLists']), - isSwimlanesOn() { - return this.glFeatures.boardsWithSwimlanes && this.isShowingEpicsSwimlanes; + ...mapState(['boardLists', 'error']), + ...mapGetters(['isSwimlanesOn']), + boardListsToUse() { + return this.glFeatures.graphqlBoardLists ? this.boardLists : this.lists; }, }, + mounted() { + if (this.glFeatures.graphqlBoardLists) { + this.fetchLists(); + this.showPromotionList(); + } + }, + methods: { + ...mapActions(['fetchLists', 'showPromotionList']), + }, }; </script> <template> <div> + <gl-alert v-if="error" variant="danger" :dismissible="false"> + {{ error }} + </gl-alert> <div v-if="!isSwimlanesOn" class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" data-qa-selector="boards_list" > <board-column - v-for="list in lists" + v-for="list in boardListsToUse" :key="list.id" ref="board" :can-admin-list="canAdminList" - :group-id="groupId" :list="list" :disabled="disabled" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :board-id="boardId" /> </div> - <epics-swimlanes - v-else - ref="swimlanes" - :lists="boardLists" - :can-admin-list="canAdminList" - :disabled="disabled" - :board-id="boardId" - :group-id="groupId" - :root-path="rootPath" - /> + + <template v-else> + <epics-swimlanes + ref="swimlanes" + :lists="boardLists" + :can-admin-list="canAdminList" + :disabled="disabled" + /> + <board-content-sidebar /> + </template> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 231059b895e..385dd5fdc71 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -25,11 +25,11 @@ export default { type: Boolean, required: true, }, - milestonePath: { + labelsPath: { type: String, required: true, }, - labelsPath: { + labelsWebUrl: { type: String, required: true, }, @@ -201,8 +201,8 @@ export default { :collapse-scope="isNewForm" :board="board" :can-admin-board="canAdminBoard" - :milestone-path="milestonePath" :labels-path="labelsPath" + :labels-web-url="labelsWebUrl" :enable-scoped-labels="enableScopedLabels" :project-id="projectId" :group-id="groupId" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1a26782f6f0..25f8ffca633 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,6 +6,7 @@ import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, @@ -24,12 +25,8 @@ export default { boardNewIssue, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], props: { - groupId: { - type: Number, - required: false, - default: 0, - }, disabled: { type: Boolean, required: true, @@ -46,14 +43,6 @@ export default { type: Boolean, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, }, data() { return { @@ -83,6 +72,7 @@ export default { deep: true, }, issues() { + if (this.glFeatures.graphqlBoardLists) return; this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && @@ -413,6 +403,8 @@ export default { this.showIssueForm = !this.showIssueForm; }, onScroll() { + if (this.glFeatures.graphqlBoardLists) return; + if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { this.loadNextPage(); } @@ -430,11 +422,7 @@ export default { <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" - :group-id="groupId" - :list="list" - /> + <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> <ul v-show="!loading" ref="list" @@ -450,9 +438,6 @@ export default { :index="index" :list="list" :issue="issue" - :issue-link-base="issueLinkBase" - :group-id="groupId" - :root-path="rootPath" :disabled="disabled" /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bafe07afb48..361fe252afb 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,4 +1,5 @@ <script> +import { mapActions } from 'vuex'; import { GlButton, GlButtonGroup, @@ -17,6 +18,7 @@ import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; import { ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -32,7 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [isWipLimitsOn], + mixins: [isWipLimitsOn, glFeatureFlagMixin()], props: { list: { type: Object, @@ -43,10 +45,6 @@ export default { type: Boolean, required: true, }, - boardId: { - type: String, - required: true, - }, canAdminList: { type: Boolean, required: false, @@ -58,6 +56,11 @@ export default { default: false, }, }, + inject: { + boardId: { + type: String, + }, + }, data() { return { weightFeatureAvailable: false, @@ -94,10 +97,11 @@ export default { showAssigneeListDetails() { return this.list.type === 'assignee' && (this.list.isExpanded || !this.isSwimlanesHeader); }, + issuesCount() { + return this.list.issuesSize; + }, issuesTooltipLabel() { - const { issuesSize } = this.list; - - return n__(`%d issue`, `%d issues`, issuesSize); + return n__(`%d issue`, `%d issues`, this.issuesCount); }, chevronTooltip() { return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); @@ -126,8 +130,12 @@ export default { collapsedTooltipTitle() { return this.listTitle || this.listAssignee; }, + shouldDisplaySwimlanes() { + return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; + }, }, methods: { + ...mapActions(['updateList']), showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, @@ -136,20 +144,28 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { - if (this.list.isExpandable) { - this.list.isExpanded = !this.list.isExpanded; - - if (AccessorUtilities.isLocalStorageAccessSafe() && !this.isLoggedIn) { - localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); - } + this.list.isExpanded = !this.list.isExpanded; - if (this.isLoggedIn) { - this.list.update(); - } + if (!this.isLoggedIn) { + this.addToLocalStorage(); + } else { + this.updateListFunction(); + } - // When expanding/collapsing, the tooltip on the caret button sometimes stays open. - // Close all tooltips manually to prevent dangling tooltips. - this.$root.$emit('bv::hide::tooltip'); + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + this.$root.$emit('bv::hide::tooltip'); + }, + addToLocalStorage() { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + } + }, + updateListFunction() { + if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { + this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); + } else { + this.list.update(); } }, }, @@ -172,7 +188,7 @@ export default { <h3 :class="{ 'user-can-drag': !disabled && !list.preset, - 'gl-py-3': !list.isExpanded && !isSwimlanesHeader, + 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, 'gl-py-2': !list.isExpanded && isSwimlanesHeader, }" @@ -288,7 +304,7 @@ export default { <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> <span ref="issueCount" class="issue-count-badge-count"> <gl-icon class="gl-mr-2" name="issues" /> - <issue-count :issues-size="list.issuesSize" :max-issue-count="list.maxIssueCount" /> + <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> </span> <!-- The following is only true in EE. --> <template v-if="weightFeatureAvailable"> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 34e8438ba4c..348d485ff37 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,11 +1,13 @@ <script> import $ from 'jquery'; +import { mapActions, mapGetters } 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'; export default { name: 'BoardNewIssue', @@ -13,17 +15,18 @@ export default { ProjectSelect, GlButton, }, + mixins: [glFeatureFlagMixin()], props: { - groupId: { - type: Number, - required: false, - default: 0, - }, list: { type: Object, required: true, }, }, + inject: { + groupId: { + type: Number, + }, + }, data() { return { title: '', @@ -32,18 +35,23 @@ export default { }; }, computed: { + ...mapGetters(['isSwimlanesOn']), disabled() { if (this.groupId) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; }, + shouldDisplaySwimlanes() { + return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; + }, }, mounted() { this.$refs.input.focus(); eventHub.$on('setSelectedProject', this.setSelectedProject); }, methods: { + ...mapActions(['addListIssue', 'addListIssueFailure']), submit(e) { e.preventDefault(); if (this.title.trim() === '') return Promise.resolve(); @@ -70,21 +78,31 @@ export default { eventHub.$emit(`scroll-board-list-${this.list.id}`); this.cancel(); + if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { + this.addListIssue({ list: this.list, issue, position: 0 }); + } + return this.list .newIssue(issue) .then(() => { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); - boardsStore.setIssueDetail(issue); - boardsStore.setListDetail(this.list); + if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) { + boardsStore.setIssueDetail(issue); + boardsStore.setListDetail(this.list); + } }) .catch(() => { // Need this because our jQuery very kindly disables buttons on ALL form submissions $(this.$refs.submitButton).enable(); // Remove the issue - this.list.removeIssue(issue); + if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { + this.addListIssueFailure({ list: this.list, issue }); + } else { + this.list.removeIssue(issue); + } // Show error message this.error = true; @@ -121,7 +139,7 @@ export default { <project-select v-if="groupId" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button - ref="submit-button" + ref="submitButton" :disabled="disabled" class="float-left" variant="success" @@ -129,9 +147,14 @@ export default { type="submit" >{{ __('Submit issue') }}</gl-button > - <gl-button class="float-right" type="button" variant="default" @click="cancel">{{ - __('Cancel') - }}</gl-button> + <gl-button + ref="cancelButton" + class="float-right" + type="button" + variant="default" + @click="cancel" + >{{ __('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 3149762ecdf..e2600883e89 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,11 +1,12 @@ <script> import { GlDrawer, GlLabel } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; import boardsStore from '~/boards/stores/boards_store'; import eventHub from '~/sidebar/event_hub'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { inactiveId } from '~/boards/constants'; +import { LIST } from '~/boards/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; // NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. export default { @@ -23,18 +24,20 @@ export default { BoardSettingsListTypes: () => import('ee_component/boards/components/board_settings_list_types.vue'), }, + mixins: [glFeatureFlagMixin()], computed: { - ...mapState(['activeId']), + ...mapGetters(['isSidebarOpen']), + ...mapState(['activeId', 'sidebarType', 'boardLists']), activeList() { /* Warning: Though a computed property it is not reactive because we are referencing a List Model class. Reactivity only applies to plain JS objects */ + if (this.glFeatures.graphqlBoardLists) { + return this.boardLists.find(({ id }) => id === this.activeId); + } return boardsStore.state.lists.find(({ id }) => id === this.activeId); }, - isSidebarOpen() { - return this.activeId !== inactiveId; - }, activeListLabel() { return this.activeList.label; }, @@ -44,18 +47,18 @@ export default { listTypeTitle() { return this.$options.labelListText; }, + showSidebar() { + return this.sidebarType === LIST; + }, }, created() { - eventHub.$on('sidebar.closeAll', this.closeSidebar); + eventHub.$on('sidebar.closeAll', this.unsetActiveId); }, beforeDestroy() { - eventHub.$off('sidebar.closeAll', this.closeSidebar); + eventHub.$off('sidebar.closeAll', this.unsetActiveId); }, methods: { - ...mapActions(['setActiveId']), - closeSidebar() { - this.setActiveId(inactiveId); - }, + ...mapActions(['unsetActiveId']), showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, @@ -65,10 +68,11 @@ export default { <template> <gl-drawer + v-if="showSidebar" class="js-board-settings-sidebar" :open="isSidebarOpen" :header-height="$options.headerHeight" - @close="closeSidebar" + @close="unsetActiveId" > <template #header>{{ $options.listSettingsText }}</template> <template v-if="isSidebarOpen"> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 3790c494085..d26f15c1723 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -83,7 +83,7 @@ export default Vue.extend({ $('.js-issue-board-sidebar', this.$el).each((i, el) => { $(el) - .data('glDropdown') + .data('deprecatedJQueryDropdown') .clearMenu(); }); } @@ -95,7 +95,7 @@ export default Vue.extend({ }, }, created() { - // Get events from glDropdown + // Get events from deprecatedJQueryDropdown eventHub.$on('sidebar.removeAssignee', this.removeAssignee); eventHub.$on('sidebar.addAssignee', this.addAssignee); eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 48f6ba6cfc7..271e1fc4b5f 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -36,10 +36,6 @@ export default { type: Object, required: true, }, - milestonePath: { - type: String, - required: true, - }, throttleDuration: { type: Number, default: 200, @@ -65,6 +61,10 @@ export default { type: String, required: true, }, + labelsWebUrl: { + type: String, + required: true, + }, projectId: { type: Number, required: true, @@ -335,8 +335,8 @@ export default { <board-form v-if="currentPage" - :milestone-path="milestonePath" :labels-path="labelsPath" + :labels-web-url="labelsWebUrl" :project-id="projectId" :group-id="groupId" :can-admin-board="canAdminBoard" diff --git a/app/assets/javascripts/boards/components/issuable_title.vue b/app/assets/javascripts/boards/components/issuable_title.vue new file mode 100644 index 00000000000..40627a9fab8 --- /dev/null +++ b/app/assets/javascripts/boards/components/issuable_title.vue @@ -0,0 +1,21 @@ +<script> +export default { + props: { + title: { + type: String, + required: true, + }, + refPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div data-testid="issue-title"> + <p class="gl-font-weight-bold">{{ title }}</p> + <p class="gl-mb-0">{{ refPath }}</p> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index d90928f35b6..8658f51e5cf 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,10 +1,9 @@ <script> import { sortBy } from 'lodash'; import { mapState } from 'vuex'; -import { GlLabel, GlTooltipDirective } from '@gitlab/ui'; +import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; import { sprintf, __ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; 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'; @@ -15,7 +14,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; export default { components: { GlLabel, - Icon, + GlIcon, UserAvatarLink, TooltipOnTruncate, IssueDueDate, @@ -31,28 +30,23 @@ export default { type: Object, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, list: { type: Object, required: false, default: () => ({}), }, - rootPath: { - type: String, - required: true, - }, updateFilters: { type: Boolean, required: false, default: false, }, + }, + inject: { groupId: { type: Number, - required: false, - default: null, + }, + rootPath: { + type: String, }, }, data() { @@ -148,7 +142,7 @@ export default { <div> <div class="d-flex board-card-header" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> - <icon + <gl-icon v-if="issue.blocked" v-gl-tooltip name="issue-block" @@ -156,7 +150,7 @@ export default { class="issue-blocked-icon gl-mr-2" :aria-label="__('Blocked issue')" /> - <icon + <gl-icon v-if="issue.confidential" v-gl-tooltip name="eye-slash" diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 4add5ee646a..fb45de6e14d 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -1,7 +1,6 @@ <script> import dateFormat from 'dateformat'; -import { GlTooltip } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltip, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { getDayDifference, @@ -12,7 +11,7 @@ import { export default { components: { - Icon, + GlIcon, GlTooltip, }, props: { @@ -87,7 +86,7 @@ export default { <template> <span> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> - <icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" /> + <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" /> <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index e8b7689da13..fe56833016e 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -1,12 +1,11 @@ <script> -import { GlTooltip } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltip, GlIcon } from '@gitlab/ui'; import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import boardsStore from '../stores/boards_store'; export default { components: { - Icon, + GlIcon, GlTooltip, }, props: { @@ -34,7 +33,7 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{ + <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{ timeEstimate }}</time> </span> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index 66f59009714..cd4512f320f 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,9 +1,14 @@ <script> +/* eslint-disable vue/no-v-html */ +import { GlButton } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { + components: { + GlButton, + }, mixins: [modalMixin], props: { newIssuePath: { @@ -53,17 +58,22 @@ export default { <div class="text-content"> <h4>{{ contents.title }}</h4> <p v-html="contents.content"></p> - <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">{{ - __('New issue') - }}</a> - <button + <gl-button + v-if="activeTab === 'all'" + :href="newIssuePath" + category="secondary" + variant="success" + > + {{ __('New issue') }} + </gl-button> + <gl-button v-if="activeTab === 'selected'" - class="btn btn-default" - type="button" + category="primary" + variant="default" @click="changeTab('all')" > {{ __('Open issues') }} - </button> + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index c4953dda793..d28a03da97f 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,4 +1,5 @@ <script> +import { GlButton } from '@gitlab/ui'; import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __, n__ } from '../../../locale'; @@ -10,6 +11,7 @@ import boardsStore from '../../stores/boards_store'; export default { components: { ListsDropdown, + GlButton, }, mixins: [modalMixin, footerEEMixin], data() { @@ -65,14 +67,14 @@ export default { <template> <footer class="form-actions add-issues-footer"> <div class="float-left"> - <button :disabled="submitDisabled" class="btn btn-success" type="button" @click="addIssues"> + <gl-button :disabled="submitDisabled" category="primary" variant="success" @click="addIssues"> {{ submitText }} - </button> + </gl-button> <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span> <lists-dropdown /> </div> - <button class="btn btn-default float-right" type="button" @click="toggleModal(false)"> + <gl-button class="float-right" @click="toggleModal(false)"> {{ __('Cancel') }} - </button> + </gl-button> </footer> </template> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 573284d2b44..3e96ecca24c 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -1,5 +1,6 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import ModalFilters from './filters'; import ModalTabs from './tabs.vue'; @@ -10,6 +11,7 @@ export default { components: { ModalTabs, ModalFilters, + GlButton, }, mixins: [modalMixin], props: { @@ -17,10 +19,6 @@ export default { type: Number, required: true, }, - milestonePath: { - type: String, - required: true, - }, labelPath: { type: String, required: true, @@ -43,7 +41,7 @@ export default { }, methods: { toggleAll() { - this.$refs.selectAllBtn.blur(); + this.$refs.selectAllBtn.$el.blur(); ModalStore.toggleAll(); }, @@ -55,28 +53,28 @@ export default { <header class="add-issues-header border-top-0 form-actions"> <h2 class="m-0"> Add issues - <button - type="button" + <gl-button + category="tertiary" + icon="close" class="close" data-dismiss="modal" :aria-label="__('Close')" @click="toggleModal(false)" - > - <span aria-hidden="true">×</span> - </button> + /> </h2> </header> <modal-tabs v-if="!loading && issuesCount > 0" /> <div v-if="showSearch" class="d-flex gl-mb-3"> <modal-filters :store="filter" /> - <button + <gl-button ref="selectAllBtn" - type="button" - class="btn btn-success btn-inverted gl-ml-3" + category="secondary" + variant="success" + class="gl-ml-3" @click="toggleAll" > {{ selectAllText }} - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index 20344b66140..817b3bdddb0 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -26,22 +26,10 @@ export default { type: String, required: true, }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, projectId: { type: Number, required: true, }, - milestonePath: { - type: String, - required: true, - }, labelPath: { type: String, required: true, @@ -149,17 +137,8 @@ export default { class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100" > <div class="add-issues-container d-flex flex-column m-auto rounded"> - <modal-header - :project-id="projectId" - :milestone-path="milestonePath" - :label-path="labelPath" - /> - <modal-list - v-if="!loading && showList && !filterLoading" - :issue-link-base="issueLinkBase" - :root-path="rootPath" - :empty-state-svg="emptyStateSvg" - /> + <modal-header :project-id="projectId" :label-path="labelPath" /> + <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" /> <empty-state v-if="showEmptyState" :new-issue-path="newIssuePath" diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index 78e3351a79e..219263bd9b9 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -1,23 +1,15 @@ <script> import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlIcon } from '@gitlab/ui'; import ModalStore from '../../stores/modal_store'; import IssueCardInner from '../issue_card_inner.vue'; export default { components: { IssueCardInner, - Icon, + GlIcon, }, props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, emptyStateSvg: { type: String, required: true, @@ -134,8 +126,8 @@ export default { class="board-card position-relative p-3 rounded" @click="toggleIssue($event, issue)" > - <issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" /> - <icon + <issue-card-inner :issue="issue" /> + <gl-icon v-if="issue.selected" :aria-label="'Issue #' + issue.id + ' selected'" name="mobile-issue-close" diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue index 3fbe8fe1be7..fe10e7fb856 100644 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue @@ -1,13 +1,12 @@ <script> -import { GlLink } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLink, GlIcon } from '@gitlab/ui'; import ModalStore from '../../stores/modal_store'; import boardsStore from '../../stores/boards_store'; export default { components: { GlLink, - Icon, + GlIcon, }, data() { return { @@ -29,7 +28,7 @@ export default { <div class="dropdown inline"> <button class="dropdown-menu-toggle" type="button" data-toggle="dropdown" aria-expanded="false"> <span :style="{ backgroundColor: selected.label.color }" class="dropdown-label-box"> </span> - {{ selected.title }} <icon name="chevron-down" /> + {{ selected.title }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" /> </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> <ul> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2b9fdf11b37..2e356f1353a 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -6,6 +6,7 @@ import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; $(document) .off('created.label') @@ -36,7 +37,7 @@ export default function initNewListDropdown() { $dropdownToggle.data('projectPath'), ); - $dropdownToggle.glDropdown({ + initDeprecatedJQueryDropdown($dropdownToggle, { data(term, callback) { axios .get($dropdownToggle.attr('data-list-labels-path')) diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 598e92726c1..59e7620962a 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -1,30 +1,30 @@ <script> import $ from 'jquery'; import { escape } from 'lodash'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../eventhub'; import Api from '../../api'; import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default { name: 'BoardProjectSelect', components: { - Icon, + GlIcon, GlLoadingIcon, }, props: { - groupId: { - type: Number, - required: true, - default: 0, - }, list: { type: Object, required: true, }, }, + inject: { + groupId: { + type: Number, + }, + }, data() { return { loading: true, @@ -37,7 +37,7 @@ export default { }, }, mounted() { - $(this.$refs.projectsDropdown).glDropdown({ + initDeprecatedJQueryDropdown($(this.$refs.projectsDropdown), { filterable: true, filterRemote: true, search: { @@ -105,13 +105,13 @@ export default { data-toggle="dropdown" aria-expanded="false" > - {{ selectedProjectName }} <icon name="chevron-down" /> + {{ 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')" /> - <icon name="search" class="dropdown-input-search" data-hidden="true" /> + <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> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue new file mode 100644 index 00000000000..8df03ea581f --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -0,0 +1,79 @@ +<script> +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; + +export default { + components: { GlButton, GlLoadingIcon }, + props: { + title: { + type: String, + required: false, + default: '', + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: ['canUpdate'], + data() { + return { + edit: false, + }; + }, + destroyed() { + window.removeEventListener('click', this.collapseWhenOffClick); + }, + methods: { + collapseWhenOffClick({ target }) { + if (!this.$el.contains(target)) { + this.collapse(); + } + }, + expand() { + if (this.edit) { + return; + } + + this.edit = true; + this.$emit('changed', this.edit); + window.addEventListener('click', this.collapseWhenOffClick); + }, + collapse() { + if (!this.edit) { + return; + } + + this.edit = false; + this.$emit('changed', this.edit); + window.removeEventListener('click', this.collapseWhenOffClick); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <span class="gl-vertical-align-middle"> + <span data-testid="title">{{ title }}</span> + <gl-loading-icon v-if="loading" inline class="gl-ml-2" /> + </span> + <gl-button + v-if="canUpdate" + variant="link" + class="gl-text-gray-900!" + data-testid="edit-button" + @click="expand()" + > + {{ __('Edit') }} + </gl-button> + </div> + <div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content"> + <slot name="collapsed">{{ __('None') }}</slot> + </div> + <div v-show="edit" data-testid="expanded-content"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 35c52558cac..2f64014a949 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -15,6 +15,9 @@ export const ListType = { export const inactiveId = 0; +export const ISSUABLE = 'issuable'; +export const LIST = 'list'; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index b7966dd869d..fff89832bf0 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -27,6 +27,11 @@ export default class FilteredSearchBoards extends FilteredSearchManager { updateObject(path) { this.store.path = path.substr(1); + if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) { + boardsStore.updateFiltersUrl(); + boardsStore.performSearch(); + } + if (this.updateUrl) { boardsStore.updateFiltersUrl(); } diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 971edd71eec..1173c6d0578 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; @@ -24,7 +24,6 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; import './models/label'; import './models/assignee'; -import { BoardType } from './constants'; import toggleFocusMode from '~/boards/toggle_focus'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; @@ -42,11 +41,9 @@ import { NavigationType, convertObjectPropsToCamelCase, parseBoolean, + urlParamsToObject, } from '~/lib/utils/common_utils'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; -import projectBoardQuery from './queries/project_board.query.graphql'; -import groupQuery from './queries/group_board.query.graphql'; Vue.use(VueApollo); @@ -85,6 +82,11 @@ export default () => { BoardAddIssuesModal, BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), }, + provide: { + boardId: $boardApp.dataset.boardId, + groupId: Number($boardApp.dataset.groupId) || null, + rootPath: $boardApp.dataset.rootPath, + }, store, apolloProvider, data() { @@ -94,16 +96,14 @@ export default () => { boardsEndpoint: $boardApp.dataset.boardsEndpoint, recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint, listsEndpoint: $boardApp.dataset.listsEndpoint, - boardId: $boardApp.dataset.boardId, disabled: parseBoolean($boardApp.dataset.disabled), - issueLinkBase: $boardApp.dataset.issueLinkBase, - rootPath: $boardApp.dataset.rootPath, bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, detailIssue: boardsStore.detail, parent: $boardApp.dataset.parent, }; }, computed: { + ...mapState(['isShowingEpicsSwimlanes']), detailIssueVisible() { return Object.keys(this.detailIssue.issue).length; }, @@ -114,10 +114,15 @@ export default () => { recentBoardsEndpoint: this.recentBoardsEndpoint, listsEndpoint: this.listsEndpoint, bulkUpdatePath: this.bulkUpdatePath, - boardId: this.boardId, + boardId: $boardApp.dataset.boardId, fullPath: $boardApp.dataset.fullPath, }; - this.setInitialBoardData({ ...endpoints, boardType: this.parent }); + this.setInitialBoardData({ + ...endpoints, + boardType: this.parent, + disabled: this.disabled, + showPromotion: parseBoolean($boardApp.getAttribute('data-show-promotion')), + }); boardsStore.setEndpoints(endpoints); boardsStore.rootPath = this.boardsEndpoint; @@ -125,55 +130,24 @@ export default () => { eventHub.$on('newDetailIssue', this.updateDetailIssue); eventHub.$on('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); + eventHub.$on('performSearch', this.performSearch); }, beforeDestroy() { eventHub.$off('updateTokens', this.updateTokens); eventHub.$off('newDetailIssue', this.updateDetailIssue); eventHub.$off('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); + eventHub.$off('performSearch', this.performSearch); }, mounted() { this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); this.filterManager.setup(); - boardsStore.disabled = this.disabled; - - if (gon.features.graphqlBoardLists) { - this.$apollo.addSmartQuery('lists', { - query() { - return this.parent === BoardType.group ? groupQuery : projectBoardQuery; - }, - variables() { - return { - fullPath: this.state.endpoints.fullPath, - boardId: `gid://gitlab/Board/${this.boardId}`, - }; - }, - update(data) { - return this.getNodes(data); - }, - result({ data, error }) { - if (error) { - throw error; - } - - const lists = this.getNodes(data); + this.performSearch(); - lists.forEach(list => - boardsStore.addList({ - ...list, - id: getIdFromGraphQLId(list.id), - }), - ); + boardsStore.disabled = this.disabled; - boardsStore.addBlankState(); - setPromotionState(boardsStore); - }, - error() { - Flash(__('An error occurred while fetching the board lists. Please try again.')); - }, - }); - } else { + if (!gon.features.graphqlBoardLists) { boardsStore .all() .then(res => res.data) @@ -189,10 +163,22 @@ export default () => { } }, methods: { - ...mapActions(['setInitialBoardData']), + ...mapActions([ + 'setInitialBoardData', + 'setFilters', + 'fetchEpicsSwimlanes', + 'fetchIssuesForAllLists', + ]), updateTokens() { this.filterManager.updateTokens(); }, + performSearch() { + this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search))); + if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) { + this.fetchEpicsSwimlanes(false); + this.fetchIssuesForAllLists(); + } + }, updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { @@ -354,6 +340,8 @@ export default () => { class="btn btn-success gl-ml-3" type="button" data-placement="bottom" + data-track-event="click_button" + data-track-label="board_add_issues" ref="addIssuesButton" :class="{ 'disabled': disabled }" :title="tooltipTitle" diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 98eac35b2ed..822e6d62ab3 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -15,7 +15,7 @@ class ListIssue { this.labels = []; this.assignees = []; this.selected = false; - this.position = obj.position || obj.relative_position || Infinity; + this.position = obj.position || obj.relative_position || obj.relativePosition || Infinity; this.isFetching = { subscriptions: true, }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index b8b30c958a9..2f6caffbf84 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -47,7 +47,7 @@ class List { this.loading = true; this.loadingMore = false; this.issues = obj.issues || []; - this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; + this.issuesSize = obj.issuesSize || obj.issuesCount || 0; this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0; if (obj.label) { diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 73d37459bfe..51bb72b7657 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -27,7 +27,7 @@ export default () => { hasMissingBoards: parseBoolean(dataset.hasMissingBoards), canAdminBoard: parseBoolean(dataset.canAdminBoard), multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), - projectId: Number(dataset.projectId), + projectId: dataset.projectId ? Number(dataset.projectId) : 0, groupId: Number(dataset.groupId), scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), weights: JSON.parse(dataset.weights), diff --git a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql new file mode 100644 index 00000000000..dcfe69222a0 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql @@ -0,0 +1,10 @@ +#import "./board_list.fragment.graphql" + +mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean) { + boardListCreate(input: { boardId: $boardId, backlog: $backlog }) { + list { + ...BoardListFragment + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql index 8abd79332fb..d85b736720b 100644 --- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql @@ -4,7 +4,7 @@ fragment BoardListShared on BoardList { position listType collapsed - maxIssueCount + issuesCount label { id title diff --git a/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql new file mode 100644 index 00000000000..b474c9acb93 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql @@ -0,0 +1,10 @@ +#import "./board_list.fragment.graphql" + +mutation UpdateBoardList($listId: ID!, $position: Int, $collapsed: Boolean) { + updateBoardList(input: { listId: $listId, position: $position, collapsed: $collapsed }) { + list { + ...BoardListFragment + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql b/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql deleted file mode 100644 index 724c7884c58..00000000000 --- a/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -#import "./issue.fragment.graphql" - -query GroupListIssues($fullPath: ID!, $boardId: ID!) { - group(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - id - issues { - nodes { - ...IssueNode - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/queries/issue.fragment.graphql index 89d56b895a4..4b429f875a6 100644 --- a/app/assets/javascripts/boards/queries/issue.fragment.graphql +++ b/app/assets/javascripts/boards/queries/issue.fragment.graphql @@ -7,14 +7,10 @@ fragment IssueNode on Issue { referencePath: reference(full: true) dueDate timeEstimate - weight confidential webUrl subscribed - blocked - epic { - id - } + relativePosition assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql new file mode 100644 index 00000000000..ff6aa597f48 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql @@ -0,0 +1,28 @@ +#import "ee_else_ce/boards/queries/issue.fragment.graphql" + +mutation IssueMoveList( + $projectPath: ID! + $iid: String! + $boardId: ID! + $fromListId: ID + $toListId: ID + $moveBeforeId: ID + $moveAfterId: ID +) { + issueMoveList( + input: { + projectPath: $projectPath + iid: $iid + boardId: $boardId + fromListId: $fromListId + toListId: $toListId + moveBeforeId: $moveBeforeId + moveAfterId: $moveAfterId + } + ) { + issue { + ...IssueNode + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/queries/lists_issues.query.graphql new file mode 100644 index 00000000000..c66cdf68cf4 --- /dev/null +++ b/app/assets/javascripts/boards/queries/lists_issues.query.graphql @@ -0,0 +1,39 @@ +#import "ee_else_ce/boards/queries/issue.fragment.graphql" + +query ListIssues( + $fullPath: ID! + $boardId: ID! + $id: ID + $filters: BoardIssueInput + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + board(id: $boardId) { + lists(id: $id) { + nodes { + id + issues(filters: $filters) { + nodes { + ...IssueNode + } + } + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + board(id: $boardId) { + lists(id: $id) { + nodes { + id + issues(filters: $filters) { + nodes { + ...IssueNode + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql b/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql deleted file mode 100644 index 149b76848ef..00000000000 --- a/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -#import "./issue.fragment.graphql" - -query ProjectListIssues($fullPath: ID!, $boardId: ID!) { - project(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - id - issues { - nodes { - ...IssueNode - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index b4be7546252..4b81d9c73ef 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,66 +1,248 @@ -import * as types from './mutation_types'; +import Cookies from 'js-cookie'; +import { sortBy, pick } from 'lodash'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; -import { BoardType } from '~/boards/constants'; -import { formatListIssues } from '../boards_util'; -import groupListsIssuesQuery from '../queries/group_lists_issues.query.graphql'; -import projectListsIssuesQuery from '../queries/project_lists_issues.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { BoardType, ListType, inactiveId } from '~/boards/constants'; +import * as types from './mutation_types'; +import { formatListIssues, fullBoardId } from '../boards_util'; +import boardStore from '~/boards/stores/boards_store'; -const gqlClient = createDefaultClient(); +import listsIssuesQuery from '../queries/lists_issues.query.graphql'; +import projectBoardQuery from '../queries/project_board.query.graphql'; +import groupBoardQuery from '../queries/group_board.query.graphql'; +import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; +import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; +import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('Not implemented!'); }; +export const gqlClient = createDefaultClient(); + export default { setInitialBoardData: ({ commit }, data) => { commit(types.SET_INITIAL_BOARD_DATA, data); }, - setActiveId({ commit }, id) { - commit(types.SET_ACTIVE_ID, id); + setActiveId({ commit }, { id, sidebarType }) { + commit(types.SET_ACTIVE_ID, { id, sidebarType }); }, - fetchLists: () => { - notImplemented(); + unsetActiveId({ dispatch }) { + dispatch('setActiveId', { id: inactiveId, sidebarType: '' }); }, + setFilters: ({ commit }, filters) => { + const filterParams = pick(filters, [ + 'assigneeUsername', + 'authorUsername', + 'labelName', + 'milestoneTitle', + 'releaseTag', + 'search', + ]); + commit(types.SET_FILTERS, filterParams); + }, + + fetchLists: ({ commit, state, dispatch }) => { + const { endpoints, boardType } = state; + const { fullPath, boardId } = endpoints; + + let query; + if (boardType === BoardType.group) { + query = groupBoardQuery; + } else if (boardType === BoardType.project) { + query = projectBoardQuery; + } else { + createFlash(__('Invalid board')); + return Promise.reject(); + } + + const variables = { + fullPath, + boardId: fullBoardId(boardId), + }; + + return gqlClient + .query({ + query, + variables, + }) + .then(({ data }) => { + let { lists } = data[boardType]?.board; + // Temporarily using positioning logic from boardStore + lists = lists.nodes.map(list => + boardStore.updateListPosition({ + ...list, + doNotFetchIssues: true, + }), + ); + commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position')); + // Backlog list needs to be created if it doesn't exist + if (!lists.find(l => l.type === ListType.backlog)) { + dispatch('createList', { backlog: true }); + } + dispatch('showWelcomeList'); + }) + .catch(() => { + createFlash( + __('An error occurred while fetching the board lists. Please reload the page.'), + ); + }); + }, + + // This action only supports backlog list creation at this stage + // Future iterations will add the ability to create other list types + createList: ({ state, commit, dispatch }, { backlog = false }) => { + const { boardId } = state.endpoints; + gqlClient + .mutate({ + mutation: createBoardListMutation, + variables: { + boardId: fullBoardId(boardId), + backlog, + }, + }) + .then(({ data }) => { + if (data?.boardListCreate?.errors.length) { + commit(types.CREATE_LIST_FAILURE); + } else { + const list = data.boardListCreate?.list; + dispatch('addList', list); + } + }) + .catch(() => { + commit(types.CREATE_LIST_FAILURE); + }); + }, + + addList: ({ state, commit }, list) => { + const lists = state.boardLists; + // Temporarily using positioning logic from boardStore + lists.push(boardStore.updateListPosition({ ...list, doNotFetchIssues: true })); + commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position')); + }, + + showWelcomeList: ({ state, dispatch }) => { + if (state.disabled) { + return; + } + if ( + state.boardLists.find(list => list.type !== ListType.backlog && list.type !== ListType.closed) + ) { + return; + } + if (parseBoolean(Cookies.get('issue_board_welcome_hidden'))) { + return; + } + + dispatch('addList', { + id: 'blank', + listType: ListType.blank, + title: __('Welcome to your issue board!'), + position: 0, + }); + }, + + showPromotionList: () => {}, + generateDefaultLists: () => { notImplemented(); }, - createList: () => { - notImplemented(); + moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => { + const { boardLists } = state; + const backupList = [...boardLists]; + const movedList = boardLists.find(({ id }) => id === listId); + + const newPosition = newIndex - 1; + const listAtNewIndex = boardLists[newIndex]; + + movedList.position = newPosition; + listAtNewIndex.position += adjustmentValue; + commit(types.MOVE_LIST, { + movedList, + listAtNewIndex, + }); + + dispatch('updateList', { listId, position: newPosition, backupList }); }, - updateList: () => { - notImplemented(); + updateList: ({ commit }, { listId, position, collapsed, backupList }) => { + gqlClient + .mutate({ + mutation: updateBoardListMutation, + variables: { + listId, + position, + collapsed, + }, + }) + .then(({ data }) => { + if (data?.updateBoardList?.errors.length) { + commit(types.UPDATE_LIST_FAILURE, backupList); + } + }) + .catch(() => { + commit(types.UPDATE_LIST_FAILURE, backupList); + }); }, deleteList: () => { notImplemented(); }, - fetchIssuesForList: () => { - notImplemented(); + fetchIssuesForList: ({ state, commit }, listId) => { + const { endpoints, boardType, filterParams } = state; + const { fullPath, boardId } = endpoints; + + const variables = { + fullPath, + boardId: fullBoardId(boardId), + id: listId, + filters: filterParams, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, + }; + + return gqlClient + .query({ + query: listsIssuesQuery, + context: { + isSingleRequest: true, + }, + variables, + }) + .then(({ data }) => { + const { lists } = data[boardType]?.board; + const listIssues = formatListIssues(lists); + commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId }); + }) + .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); }, fetchIssuesForAllLists: ({ state, commit }) => { commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); - const { endpoints, boardType } = state; + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; - const query = boardType === BoardType.group ? groupListsIssuesQuery : projectListsIssuesQuery; - const variables = { fullPath, - boardId: `gid://gitlab/Board/${boardId}`, + boardId: fullBoardId(boardId), + filters: filterParams, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, }; return gqlClient .query({ - query, + query: listsIssuesQuery, variables, }) .then(({ data }) => { @@ -71,14 +253,56 @@ export default { .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); }, - moveIssue: () => { - notImplemented(); + moveIssue: ( + { state, commit }, + { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId }, + ) => { + const originalIssue = state.issues[issueId]; + const fromList = state.issuesByListId[fromListId]; + const originalIndex = fromList.indexOf(Number(issueId)); + commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); + + const { boardId } = state.endpoints; + const [fullProjectPath] = issuePath.split(/[#]/); + + gqlClient + .mutate({ + mutation: issueMoveListMutation, + variables: { + projectPath: fullProjectPath, + boardId: fullBoardId(boardId), + iid: issueIid, + fromListId: getIdFromGraphQLId(fromListId), + toListId: getIdFromGraphQLId(toListId), + moveBeforeId, + moveAfterId, + }, + }) + .then(({ data }) => { + if (data?.issueMoveList?.errors.length) { + commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); + } else { + const issue = data.issueMoveList?.issue; + commit(types.MOVE_ISSUE_SUCCESS, { issue }); + } + }) + .catch(() => + commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }), + ); }, createNewIssue: () => { notImplemented(); }, + addListIssue: ({ commit }, { list, issue, position }) => { + commit(types.ADD_ISSUE_TO_LIST, { list, issue, position }); + }, + + addListIssueFailure: ({ commit }, { list, issue }) => { + commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 30c71d64085..faf4f9ebfd3 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -15,6 +15,7 @@ import { import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../eventhub'; import { ListType } from '../constants'; import IssueProject from '../models/project'; @@ -303,7 +304,11 @@ const boardsStore = { onNewListIssueResponse(list, issue, data) { issue.refreshData(data); - if (list.issuesSize > 1) { + if ( + !gon.features.boardsWithSwimlanes && + !gon.features.graphqlBoardLists && + list.issues.length > 1 + ) { const moveBeforeId = list.issues[1].id; this.moveIssue(issue.id, null, null, null, moveBeforeId); } @@ -513,6 +518,10 @@ const boardsStore = { eventHub.$emit('updateTokens'); }, + performSearch() { + eventHub.$emit('performSearch'); + }, + setListDetail(newList) { this.detail.list = newList; }, @@ -706,6 +715,10 @@ const boardsStore = { }, newIssue(id, issue) { + if (typeof id === 'string') { + id = getIdFromGraphQLId(id); + } + return axios.post(this.generateIssuesPath(id), { issue, }); @@ -714,6 +727,10 @@ const boardsStore = { newListIssue(list, issue) { list.addIssue(issue, null, 0); list.issuesSize += 1; + let listId = list.id; + if (typeof listId === 'string') { + listId = getIdFromGraphQLId(listId); + } return this.newIssue(list.id, issue) .then(res => res.data) @@ -854,21 +871,6 @@ const boardsStore = { }, refreshIssueData(issue, obj) { - // issue.id = obj.id; - // issue.iid = obj.iid; - // issue.title = obj.title; - // issue.confidential = obj.confidential; - // issue.dueDate = obj.due_date || obj.dueDate; - // issue.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; - // issue.referencePath = obj.reference_path || obj.referencePath; - // issue.path = obj.real_path || obj.webUrl; - // issue.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; - // issue.project_id = obj.project_id; - // issue.timeEstimate = obj.time_estimate || obj.timeEstimate; - // issue.assignableLabelsEndpoint = obj.assignable_labels_endpoint; - // issue.blocked = obj.blocked; - // issue.epic = obj.epic; - const convertedObj = convertObjectPropsToCamelCase(obj, { dropKeys: ['issue_sidebar_endpoint', 'real_path', 'webUrl'], }); diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 4de1576099d..3688476dc5f 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,3 +1,25 @@ +import { inactiveId } from '../constants'; + export default { getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), + isSidebarOpen: state => state.activeId !== inactiveId, + isSwimlanesOn: state => { + if (!gon?.features?.boardsWithSwimlanes) { + return false; + } + + return state.isShowingEpicsSwimlanes; + }, + getIssueById: state => id => { + return state.issues[id] || {}; + }, + + getIssues: (state, getters) => listId => { + const listIssueIds = state.issuesByListId[listId] || []; + return listIssueIds.map(id => getters.getIssueById(id)); + }, + + getActiveIssue: state => { + return state.issues[state.activeId] || {}; + }, }; diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 0f96dc2e287..f0a283f6161 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -1,25 +1,34 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; +export const SET_FILTERS = 'SET_FILTERS'; +export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; +export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; +export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; +export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; -export const REQUEST_UPDATE_LIST = 'REQUEST_UPDATE_LIST'; -export const RECEIVE_UPDATE_LIST_SUCCESS = 'RECEIVE_UPDATE_LIST_SUCCESS'; -export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR'; +export const MOVE_LIST = 'MOVE_LIST'; +export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS'; +export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; +export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS'; export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; -export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE'; -export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS'; -export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR'; +export const MOVE_ISSUE = 'MOVE_ISSUE'; +export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; +export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; +export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST'; +export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; +export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index ca9b911ce5b..faeb3e25a71 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,19 +1,55 @@ +import Vue from 'vue'; +import { sortBy, pull } from 'lodash'; +import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; +import { __ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('Not implemented!'); }; +const removeIssueFromList = (state, listId, issueId) => { + Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); +}; + +const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { + const listIssues = state.issuesByListId[listId]; + let newIndex = atIndex || 0; + if (moveBeforeId) { + newIndex = listIssues.indexOf(moveBeforeId) + 1; + } else if (moveAfterId) { + newIndex = listIssues.indexOf(moveAfterId); + } + listIssues.splice(newIndex, 0, issueId); + Vue.set(state.issuesByListId, listId, listIssues); +}; + export default { - [mutationTypes.SET_INITIAL_BOARD_DATA]: (state, data) => { - const { boardType, ...endpoints } = data; + [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { + const { boardType, disabled, showPromotion, ...endpoints } = data; state.endpoints = endpoints; state.boardType = boardType; + state.disabled = disabled; + state.showPromotion = showPromotion; }, - [mutationTypes.SET_ACTIVE_ID](state, id) { + [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { + state.boardLists = lists; + }, + + [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) { state.activeId = id; + state.sidebarType = sidebarType; + }, + + [mutationTypes.SET_FILTERS](state, filterParams) { + state.filterParams = filterParams; + }, + + [mutationTypes.CREATE_LIST_FAILURE]: state => { + state.error = __('An error occurred while creating the list. Please try again.'); }, [mutationTypes.REQUEST_ADD_LIST]: () => { @@ -28,16 +64,17 @@ export default { notImplemented(); }, - [mutationTypes.REQUEST_UPDATE_LIST]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => { - notImplemented(); + [mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => { + const { boardLists } = state; + const movedListIndex = state.boardLists.findIndex(l => l.id === movedList.id); + Vue.set(boardLists, movedListIndex, movedList); + Vue.set(boardLists, movedListIndex.position + 1, listAtNewIndex); + Vue.set(state, 'boardLists', sortBy(boardLists, 'position')); }, - [mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => { - notImplemented(); + [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { + state.error = __('An error occurred while updating the list. Please try again.'); + Vue.set(state, 'boardLists', backupList); }, [mutationTypes.REQUEST_REMOVE_LIST]: () => { @@ -52,17 +89,41 @@ export default { notImplemented(); }, + [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => { + const { listData, issues } = listIssues; + Vue.set(state, 'issues', { ...state.issues, ...issues }); + Vue.set(state.issuesByListId, listId, listData[listId]); + const listIndex = state.boardLists.findIndex(l => l.id === listId); + Vue.set(state.boardLists[listIndex], 'loading', false); + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { + state.error = __('An error occurred while fetching the board issues. Please reload the page.'); + const listIndex = state.boardLists.findIndex(l => l.id === listId); + Vue.set(state.boardLists[listIndex], 'loading', false); + }, + [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => { state.isLoadingIssues = true; }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, listIssues) => { - state.issuesByListId = listIssues; + [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => { + state.issuesByListId = listData; + state.issues = issues; state.isLoadingIssues = false; }, + [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { + if (!state.issues[issueId]) { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + throw new Error('No issue found.'); + } + + Vue.set(state.issues[issueId], prop, value); + }, + [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => { - state.listIssueFetchFailure = true; + state.error = __('An error occurred while fetching the board issues. Please reload the page.'); state.isLoadingIssues = false; }, @@ -78,16 +139,38 @@ export default { notImplemented(); }, - [mutationTypes.REQUEST_MOVE_ISSUE]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE]: ( + state, + { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }, + ) => { + const fromList = state.boardLists.find(l => l.id === fromListId); + const toList = state.boardLists.find(l => l.id === toListId); + + const issue = moveIssueListHelper(originalIssue, fromList, toList); + Vue.set(state.issues, issue.id, issue); + + removeIssueFromList(state, fromListId, issue.id); + addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); }, - [mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { + const issueId = getIdFromGraphQLId(issue.id); + Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId })); }, - [mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => { - notImplemented(); + [mutationTypes.MOVE_ISSUE_FAILURE]: ( + state, + { originalIssue, fromListId, toListId, originalIndex }, + ) => { + state.error = __('An error occurred while moving the issue. Please try again.'); + Vue.set(state.issues, originalIssue.id, originalIssue); + removeIssueFromList(state, toListId, originalIssue.id); + addIssueToList({ + state, + listId: fromListId, + issueId: originalIssue.id, + atIndex: originalIndex, + }); }, [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { @@ -102,6 +185,18 @@ export default { notImplemented(); }, + [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { + const listIssues = state.issuesByListId[list.id]; + listIssues.splice(position, 0, issue.id); + Vue.set(state.issuesByListId, list.id, listIssues); + Vue.set(state.issues, issue.id, issue); + }, + + [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { + state.error = __('An error occurred while creating the issue. Please try again.'); + removeIssueFromList(state, list.id, issue.id); + }, + [mutationTypes.SET_CURRENT_PAGE]: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index cb6930774ed..be937d68c6c 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -3,9 +3,17 @@ import { inactiveId } from '~/boards/constants'; export default () => ({ endpoints: {}, boardType: null, + disabled: false, + showPromotion: false, isShowingLabels: true, activeId: inactiveId, + sidebarType: '', + boardLists: [], issuesByListId: {}, + issues: {}, isLoadingIssues: false, - listIssueFetchFailure: false, + filterParams: {}, + error: undefined, + // TODO: remove after ce/ee split of board_content.vue + isShowingEpicsSwimlanes: false, }); |