diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 07:08:36 +0000 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /app/assets/javascripts/boards | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) | |
download | gitlab-ce-48aff82709769b098321c738f3444b9bdaa694c6.tar.gz |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
40 files changed, 926 insertions, 501 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 5c8df94ca90..6b7b0c2e28d 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -2,11 +2,24 @@ import { sortBy } from 'lodash'; import ListIssue from 'ee_else_ce/boards/models/issue'; import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import boardsStore from '~/boards/stores/boards_store'; export function getMilestone() { return null; } +export function formatBoardLists(lists) { + const formattedLists = lists.nodes.map(list => + boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }), + ); + return formattedLists.reduce((map, list) => { + return { + ...map, + [list.id]: list, + }; + }, {}); +} + export function formatIssue(issue) { return new ListIssue({ ...issue, @@ -17,9 +30,15 @@ export function formatIssue(issue) { export function formatListIssues(listIssues) { const issues = {}; + let listIssuesCount; const listData = listIssues.nodes.reduce((map, list) => { - const sortedIssues = sortBy(list.issues.nodes, 'relativePosition'); + listIssuesCount = list.issues.count; + let sortedIssues = list.issues.edges.map(issueNode => ({ + ...issueNode.node, + })); + sortedIssues = sortBy(sortedIssues, 'relativePosition'); + return { ...map, [list.id]: sortedIssues.map(i => { @@ -39,13 +58,30 @@ export function formatListIssues(listIssues) { }; }, {}); - return { listData, issues }; + return { listData, issues, listIssuesCount }; +} + +export function formatListsPageInfo(lists) { + const listData = lists.nodes.reduce((map, list) => { + return { + ...map, + [list.id]: list.issues.pageInfo, + }; + }, {}); + return listData; } export function fullBoardId(boardId) { return `gid://gitlab/Board/${boardId}`; } +export function fullLabelId(label) { + if (label.project_id !== null) { + return `gid://gitlab/ProjectLabel/${label.id}`; + } + return `gid://gitlab/GroupLabel/${label.id}`; +} + export function moveIssueListHelper(issue, fromList, toList) { if (toList.type === ListType.label) { issue.addLabel(toList.label); @@ -69,4 +105,5 @@ export default { formatIssue, formatListIssues, fullBoardId, + fullLabelId, }; diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue deleted file mode 100644 index 55e3e4a6329..00000000000 --- a/app/assets/javascripts/boards/components/board_blank_state.vue +++ /dev/null @@ -1,104 +0,0 @@ -<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: [ - new ListLabel({ title: __('To Do'), color: '#F0AD4E' }), - new ListLabel({ title: __('Doing'), color: '#5CB85C' }), - ], - }; - }, - methods: { - addDefaultLists() { - this.clearBlankState(); - - this.predefinedLabels.forEach((label, i) => { - boardsStore.addList({ - title: label.title, - position: i, - list_type: 'label', - label: { - title: label.title, - color: label.color, - }, - }); - }); - - const loadListIssues = listObj => { - const list = boardsStore.findList('title', listObj.title); - - if (!list) { - return null; - } - - list.id = listObj.id; - list.label.id = listObj.label.id; - return list.getIssues().catch(() => { - // TODO: handle request error - }); - }; - - // Save the labels - boardsStore - .generateDefaultLists() - .then(res => res.data) - .then(data => Promise.all(data.map(loadListIssues))) - .catch(() => { - boardsStore.removeList(undefined, 'label'); - Cookies.remove('issue_board_welcome_hidden', { - path: '', - }); - boardsStore.addBlankState(); - }); - }, - clearBlankState: boardsStore.removeBlankState.bind(boardsStore), - }, -}; -</script> - -<template> - <div class="board-blank-state p-3"> - <p> - {{ - s__('BoardBlankState|Add the following default lists to your Issue Board with one click:') - }} - </p> - <ul class="list-unstyled board-blank-state-list"> - <li v-for="(label, index) in predefinedLabels" :key="index"> - <span - :style="{ backgroundColor: label.color }" - class="label-color position-relative d-inline-block rounded" - ></span> - {{ label.title }} - </li> - </ul> - <p> - {{ - s__( - 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.', - ) - }} - </p> - <gl-button - category="secondary" - variant="success" - block="block" - class="gl-mb-0" - @click.stop="addDefaultLists" - > - {{ s__('BoardBlankState|Add default lists') }} - </gl-button> - <gl-button category="secondary" variant="default" block="block" @click.stop="clearBlankState"> - {{ s__("BoardBlankState|Nevermind, I'll use my own") }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 6d216911798..9295065b7b7 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,13 +1,12 @@ <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 BoardListNew from './board_list_new.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; @@ -16,14 +15,13 @@ import { ListType } from '../constants'; export default { components: { BoardPromotionState: EmptyComponent, - BoardBlankState, BoardListHeader, - BoardList, + BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, }, directives: { Tooltip, }, - mixins: [isWipLimitsOn, glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -42,7 +40,7 @@ export default { }, inject: { boardId: { - type: String, + default: '', }, }, data() { @@ -54,7 +52,7 @@ export default { computed: { ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { - return this.list.type !== ListType.blank && this.list.type !== ListType.promotion; + return this.list.type !== ListType.promotion; }, uniqueKey() { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -74,7 +72,7 @@ export default { filter: { handler() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } else { this.list.page = 1; this.list.getIssues(true).catch(() => { @@ -87,7 +85,7 @@ export default { }, mounted() { if (this.shouldFetchIssues) { - this.fetchIssuesForList(this.list.id); + this.fetchIssuesForList({ listId: this.list.id }); } const instance = this; @@ -146,9 +144,7 @@ export default { :disabled="disabled" :issues="listIssues" :list="list" - :loading="list.loading" /> - <board-blank-state v-if="canAdminList && list.id === 'blank'" /> <!-- Will be only available in EE --> <board-promotion-state v-if="list.id === 'promotion'" /> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue new file mode 100644 index 00000000000..ad3d653b905 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -0,0 +1,65 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + components: { + GlFormCheckbox, + }, + props: { + currentBoard: { + type: Object, + required: true, + }, + board: { + type: Object, + required: true, + }, + isNewForm: { + 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; + }, + }, +}; +</script> + +<template> + <div class="append-bottom-20"> + <label class="form-section-title label-bold" for="board-new-name"> + {{ __('List options') }} + </label> + <p class="text-secondary gl-mb-3"> + {{ __('Configure which lists are shown for anyone who visits this board') }} + </p> + <gl-form-checkbox + :checked="!hideBacklogList" + data-testid="backlog-list-checkbox" + @change="changeBacklogList" + >{{ __('Show the Open list') }} + </gl-form-checkbox> + <gl-form-checkbox + :checked="!hideClosedList" + data-testid="closed-list-checkbox" + @change="changeClosedList" + >{{ __('Show the Closed list') }} + </gl-form-checkbox> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index c7b3da0e672..2515f471379 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,5 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { sortBy } from 'lodash'; import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import { GlAlert } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -30,7 +31,9 @@ export default { ...mapState(['boardLists', 'error']), ...mapGetters(['isSwimlanesOn']), boardListsToUse() { - return this.glFeatures.graphqlBoardLists ? this.boardLists : this.lists; + const lists = + this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists; + return sortBy([...Object.values(lists)], 'position'); }, }, mounted() { @@ -68,7 +71,7 @@ export default { <template v-else> <epics-swimlanes ref="swimlanes" - :lists="boardLists" + :lists="boardListsToUse" :can-admin-list="canAdminList" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js deleted file mode 100644 index b74234a2e3c..00000000000 --- a/app/assets/javascripts/boards/components/board_delete.js +++ /dev/null @@ -1,30 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default Vue.extend({ - components: { - GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - }, - methods: { - deleteBoard() { - $(this.$el).tooltip('hide'); - - // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to delete this list?'))) { - this.list.destroy(); - } - }, - }, -}); diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue new file mode 100644 index 00000000000..b802ccc7882 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_extra_actions.vue @@ -0,0 +1,57 @@ +<script> +import { GlTooltip, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'BoardExtraActions', + components: { + GlTooltip, + GlButton, + }, + props: { + canAdminList: { + type: Boolean, + required: true, + }, + disabled: { + type: Boolean, + required: true, + }, + openModal: { + type: Function, + required: true, + }, + }, + computed: { + tooltipTitle() { + if (this.disabled) { + return __('Please add a list to your board first'); + } + + return ''; + }, + }, +}; +</script> + +<template> + <div class="board-extra-actions"> + <span ref="addIssuesButtonTooltip" class="gl-ml-3"> + <gl-button + v-if="canAdminList" + type="button" + data-placement="bottom" + data-track-event="click_button" + data-track-label="board_add_issues" + :disabled="disabled" + :aria-disabled="disabled" + @click="openModal" + > + {{ __('Add issues') }} + </gl-button> + </span> + <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom"> + {{ tooltipTitle }} + </gl-tooltip> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 385dd5fdc71..793c594cf16 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -5,6 +5,8 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; +import BoardConfigurationOptions from './board_configuration_options.vue'; + const boardDefaults = { id: false, name: '', @@ -13,12 +15,15 @@ const boardDefaults = { assignee: {}, assignee_id: undefined, weight: null, + hide_backlog_list: false, + hide_closed_list: false, }; export default { components: { BoardScope: () => import('ee_component/boards/components/board_scope.vue'), DeprecatedModal, + BoardConfigurationOptions, }, props: { canAdminBoard: { @@ -140,7 +145,17 @@ export default { } else { boardsStore .createBoard(this.board) - .then(resp => resp.data) + .then(resp => { + // This handles 2 use cases + // - In create call we only get one parameter, the new board + // - In update call, due to Promise.all, we get REST response in + // array index 0 + + if (Array.isArray(resp)) { + return resp[0].data; + } + return resp.data ? resp.data : resp; + }) .then(data => { visitUrl(data.board_path); }) @@ -182,7 +197,7 @@ export default { <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> <label class="form-section-title label-bold" for="board-new-name">{{ - __('Board name') + __('Title') }}</label> <input id="board-new-name" @@ -196,6 +211,12 @@ export default { /> </div> + <board-configuration-options + :is-new-form="isNewForm" + :board="board" + :current-board="currentBoard" + /> + <board-scope v-if="scopedIssueBoardFeatureEnabled" :collapse-scope="isNewForm" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 25f8ffca633..d01df44e7e4 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -14,6 +14,8 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; +// This component is being replaced in favor of './board_list_new.vue' for GraphQL boards + if (gon.features && gon.features.multiSelectBoard) { Sortable.mount(new MultiDrag()); } @@ -39,10 +41,6 @@ export default { type: Array, required: true, }, - loading: { - type: Boolean, - required: true, - }, }, data() { return { @@ -62,6 +60,9 @@ export default { issuesSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; }, + loading() { + return this.list.loading; + }, }, watch: { filters: { @@ -72,7 +73,6 @@ export default { deep: true, }, issues() { - if (this.glFeatures.graphqlBoardLists) return; this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && @@ -98,6 +98,8 @@ 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 = {}; if (gon.features && gon.features.multiSelectBoard) { multiSelectOpts.multiDrag = true; @@ -403,8 +405,6 @@ export default { this.showIssueForm = !this.showIssueForm; }, onScroll() { - if (this.glFeatures.graphqlBoardLists) return; - if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) { this.loadNextPage(); } diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 361fe252afb..bb9a1b79d91 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -1,5 +1,5 @@ <script> -import { mapActions } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import { GlButton, GlButtonGroup, @@ -9,20 +9,18 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; import { n__, s__ } from '~/locale'; import AccessorUtilities from '../../lib/utils/accessor'; -import BoardDelete from './board_delete'; import IssueCount from './issue_count.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; -import { ListType } from '../constants'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - BoardDelete, GlButtonGroup, GlButton, GlLabel, @@ -34,7 +32,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [isWipLimitsOn, glFeatureFlagMixin()], + mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -45,11 +43,6 @@ export default { type: Boolean, required: true, }, - canAdminList: { - type: Boolean, - required: false, - default: false, - }, isSwimlanesHeader: { type: Boolean, required: false, @@ -58,7 +51,7 @@ export default { }, inject: { boardId: { - type: String, + default: '', }, }, data() { @@ -67,6 +60,7 @@ export default { }; }, computed: { + ...mapState(['activeId']), isLoggedIn() { return Boolean(gon.current_user_id); }, @@ -114,10 +108,7 @@ export default { }, isSettingsShown() { return ( - this.listType !== ListType.backlog && - this.showListHeaderButton && - this.list.isExpanded && - this.isWipLimitsOn + this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded ); }, showBoardListAndBoardInfo() { @@ -135,7 +126,14 @@ export default { }, }, methods: { - ...mapActions(['updateList']), + ...mapActions(['updateList', 'setActiveId']), + openSidebarSettings() { + if (this.activeId === inactiveId) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + }, showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, @@ -176,7 +174,6 @@ export default { <header :class="{ 'has-border': list.label && list.label.color, - 'gl-relative': list.isExpanded, 'gl-h-full': !list.isExpanded, 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, }" @@ -279,22 +276,6 @@ export default { </div> </gl-tooltip> - <board-delete - v-if="canAdminList && !list.preset && list.id" - :list="list" - inline-template="true" - > - <gl-button - v-gl-tooltip.hover.bottom - :class="{ 'gl-display-none': !list.isExpanded }" - :aria-label="__('Delete list')" - class="board-delete no-drag gl-pr-0 gl-shadow-none! gl-mr-3" - :title="__('Delete list')" - icon="remove" - size="small" - @click.stop="deleteBoard" - /> - </board-delete> <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue new file mode 100644 index 00000000000..0a495d05122 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -0,0 +1,166 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +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 glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + name: 'BoardList', + components: { + BoardCard, + BoardNewIssue, + GlLoadingIcon, + }, + mixins: [glFeatureFlagMixin()], + 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: { + ...mapState(['pageInfoByListId', 'listsFlags']), + paginatedIssueText() { + return sprintf(__('Showing %{pageSize} of %{total} issues'), { + pageSize: this.issues.length, + total: this.list.issuesSize, + }); + }, + issuesSizeExceedsMax() { + return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount; + }, + hasNextPage() { + return this.pageInfoByListId[this.list.id].hasNextPage; + }, + loading() { + return this.listsFlags[this.list.id]?.isLoading; + }, + }, + watch: { + filters: { + handler() { + this.list.loadingMore = false; + this.$refs.list.scrollTop = 0; + }, + deep: true, + }, + 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.$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: { + ...mapActions(['fetchIssuesForList']), + 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 loadingDone = () => { + this.list.loadingMore = false; + }; + this.list.loadingMore = true; + this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }) + .then(loadingDone) + .catch(loadingDone); + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + onScroll() { + window.requestAnimationFrame(() => { + if ( + !this.list.loadingMore && + this.scrollTop() > this.scrollHeight() - this.scrollOffset && + this.hasNextPage + ) { + this.loadNextPage(); + } + }); + }, + }, +}; +</script> + +<template> + <div + v-show="list.isExpanded" + 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="__('Loading issues')" + data-testid="board_list_loading" + > + <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="{ '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" + > + <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-show="list.loadingMore" label="Loading more issues" /> + <span v-if="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_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 348d485ff37..0a665b82880 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -22,11 +22,7 @@ export default { required: true, }, }, - inject: { - groupId: { - type: Number, - }, - }, + inject: ['groupId'], data() { return { title: '', diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index e2600883e89..392e056dcbf 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,5 +1,5 @@ <script> -import { GlDrawer, GlLabel } from '@gitlab/ui'; +import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; import { __ } from '~/locale'; import boardsStore from '~/boards/stores/boards_store'; @@ -17,6 +17,7 @@ export default { label: 'label', labelListText: __('Label'), components: { + GlButton, GlDrawer, GlLabel, BoardSettingsSidebarWipLimit: () => @@ -25,16 +26,23 @@ export default { import('ee_component/boards/components/board_settings_list_types.vue'), }, mixins: [glFeatureFlagMixin()], + props: { + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { - ...mapGetters(['isSidebarOpen']), + ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...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); + if (this.shouldUseGraphQL) { + return this.boardLists[this.activeId]; } return boardsStore.state.lists.find(({ id }) => id === this.activeId); }, @@ -62,6 +70,13 @@ export default { showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, + deleteBoard() { + // eslint-disable-next-line no-alert + if (window.confirm(__('Are you sure you want to delete this list?'))) { + this.activeList.destroy(); + this.unsetActiveId(); + } + }, }, }; </script> @@ -91,6 +106,16 @@ export default { :board-list-type="boardListType" /> <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> + <gl-button + variant="danger" + category="secondary" + icon="remove" + data-testid="remove-list" + @click.stop="deleteBoard" + >{{ __('Remove list') }} + </gl-button> + </div> </template> </gl-drawer> </template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 8658f51e5cf..a181ea51c4a 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -41,14 +41,7 @@ export default { default: false, }, }, - inject: { - groupId: { - type: Number, - }, - rootPath: { - type: String, - }, - }, + inject: ['groupId', 'rootPath'], data() { return { limitBeforeCounter: 2, diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index a71fda9d7c5..b066fb25360 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -1,9 +1,15 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { + components: { + GlTabs, + GlTab, + GlBadge, + }, mixins: [modalMixin], data() { return ModalStore.store; @@ -19,18 +25,18 @@ export default { }; </script> <template> - <div class="top-area gl-mt-3 gl-mb-3"> - <ul class="nav-links issues-state-filters"> - <li :class="{ active: activeTab == 'all' }"> - <a href="#" role="button" @click.prevent="changeTab('all')"> - Open issues <span class="badge badge-pill"> {{ issuesCount }} </span> - </a> - </li> - <li :class="{ active: activeTab == 'selected' }"> - <a href="#" role="button" @click.prevent="changeTab('selected')"> - Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span> - </a> - </li> - </ul> - </div> + <gl-tabs class="gl-mt-3"> + <gl-tab @click.prevent="changeTab('all')"> + <template slot="title"> + <span>Open issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge> + </template> + </gl-tab> + <gl-tab @click.prevent="changeTab('selected')"> + <template slot="title"> + <span>Selected issues</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> </template> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2e356f1353a..c8926c5ef2a 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -6,8 +6,14 @@ 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 { fullLabelId } from '../boards_util'; +import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +function shouldCreateListGraphQL(label) { + return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label)); +} + $(document) .off('created.label') .on('created.label', (e, label, addNewList) => { @@ -15,16 +21,20 @@ $(document) return; } - boardsStore.new({ - title: label.title, - position: boardsStore.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, + if (shouldCreateListGraphQL(label)) { + store.dispatch('createList', { labelId: fullLabelId(label) }); + } else { + boardsStore.new({ title: label.title, - color: label.color, - }, - }); + position: boardsStore.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color, + }, + }); + } }); export default function initNewListDropdown() { @@ -74,7 +84,9 @@ export default function initNewListDropdown() { const label = options.selectedObj; e.preventDefault(); - if (!boardsStore.findListByLabelId(label.id)) { + if (shouldCreateListGraphQL(label)) { + store.dispatch('createList', { labelId: fullLabelId(label) }); + } else if (!boardsStore.findListByLabelId(label.id)) { boardsStore.new({ title: label.title, position: boardsStore.state.lists.length - 2, diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 59e7620962a..566c0081b9d 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -20,11 +20,7 @@ export default { required: true, }, }, - inject: { - groupId: { - type: Number, - }, - }, + inject: ['groupId'], data() { return { loading: true, 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 8df03ea581f..5fb7a9b210c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -36,16 +36,18 @@ export default { } this.edit = true; - this.$emit('changed', this.edit); + this.$emit('open'); window.addEventListener('click', this.collapseWhenOffClick); }, - collapse() { + collapse({ emitEvent = true } = {}) { if (!this.edit) { return; } this.edit = false; - this.$emit('changed', this.edit); + if (emitEvent) { + this.$emit('close'); + } window.removeEventListener('click', this.collapseWhenOffClick); }, }, 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 new file mode 100644 index 00000000000..0f063c7582e --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -0,0 +1,120 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlLabel } from '@gitlab/ui'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + LabelsSelect, + GlLabel, + }, + data() { + return { + loading: false, + }; + }, + inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], + computed: { + ...mapGetters({ issue: 'getActiveIssue' }), + selectedLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); + }, + issueLabels() { + const { labels = [] } = this.issue; + + return labels.map(label => ({ + ...label, + scoped: isScopedLabel(label), + })); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + }, + methods: { + ...mapActions(['setActiveIssueLabels']), + async setLabels(payload) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + 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); + + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred while updating labels.') }); + } finally { + this.loading = false; + } + }, + async removeLabel(id) { + this.loading = true; + + try { + const removeLabelIds = [getIdFromGraphQLId(id)]; + const input = { removeLabelIds, projectPath: this.projectPath }; + await this.setActiveIssueLabels(input); + } catch (e) { + createFlash({ message: __('An error occurred when removing the label.') }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading"> + <template #collapsed> + <gl-label + v-for="label in issueLabels" + :key="label.id" + :background-color="label.color" + :title="label.title" + :description="label.description" + :scoped="label.scoped" + :show-close-button="true" + :disabled="loading" + class="gl-mr-2 gl-mb-2" + @close="removeLabel(label.id)" + /> + </template> + <template> + <labels-select + ref="labelsSelect" + :allow-label-edit="false" + :allow-label-create="false" + :allow-multiselect="true" + :allow-scoped-labels="true" + :selected-labels="selectedLabels" + :labels-fetch-path="labelsFetchPath" + :labels-manage-path="labelsManagePath" + :labels-filter-base-path="labelsFilterBasePath" + :labels-list-title="__('Select label')" + :dropdown-button-text="__('Choose labels')" + variant="embedded" + class="gl-display-block labels gl-w-full" + @updateSelectedLabels="setLabels" + > + {{ __('None') }} + </labels-select> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index 583270fcae5..419a640d5c5 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -1,6 +1,6 @@ export const setPromotionState = () => {}; -export const setWeigthFetchingState = () => {}; +export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; export const getMilestoneTitle = () => ({}); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index fff89832bf0..4fa78ecd5a4 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -25,7 +25,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager { } updateObject(path) { - this.store.path = path.substr(1); + const groupByParam = new URLSearchParams(window.location.search).get('group_by'); + this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`; if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) { boardsStore.updateFiltersUrl(); diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg deleted file mode 100644 index 6bd773dc4c5..00000000000 --- a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg deleted file mode 100644 index 306073b8af2..00000000000 --- a/app/assets/javascripts/boards/icons/fullscreen_expand.svg +++ /dev/null @@ -1 +0,0 @@ -<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg> diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 1173c6d0578..887abe79059 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState } from 'vuex'; @@ -11,7 +10,7 @@ import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import { setPromotionState, - setWeigthFetchingState, + setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, getBoardsModalData, @@ -19,6 +18,7 @@ import { import VueApollo from 'vue-apollo'; import BoardContent from '~/boards/components/board_content.vue'; +import BoardExtraActions from '~/boards/components/board_extra_actions.vue'; import createDefaultClient from '~/lib/graphql'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; @@ -84,8 +84,12 @@ export default () => { }, provide: { boardId: $boardApp.dataset.boardId, - groupId: Number($boardApp.dataset.groupId) || null, + groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, + canUpdate: $boardApp.dataset.canUpdate, + labelsFetchPath: $boardApp.dataset.labelsFetchPath, + labelsManagePath: $boardApp.dataset.labelsManagePath, + labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, }, store, apolloProvider, @@ -131,6 +135,7 @@ export default () => { eventHub.$on('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$on('toggleSubscription', this.toggleSubscription); eventHub.$on('performSearch', this.performSearch); + eventHub.$on('initialBoardLoad', this.initialBoardLoad); }, beforeDestroy() { eventHub.$off('updateTokens', this.updateTokens); @@ -138,6 +143,7 @@ export default () => { eventHub.$off('clearDetailIssue', this.clearDetailIssue); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); eventHub.$off('performSearch', this.performSearch); + eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); @@ -148,6 +154,19 @@ export default () => { boardsStore.disabled = this.disabled; if (!gon.features.graphqlBoardLists) { + this.initialBoardLoad(); + } + }, + methods: { + ...mapActions([ + 'setInitialBoardData', + 'setFilters', + 'fetchEpicsSwimlanes', + 'resetIssues', + 'resetEpics', + 'fetchLists', + ]), + initialBoardLoad() { boardsStore .all() .then(res => res.data) @@ -160,30 +179,26 @@ export default () => { .catch(() => { Flash(__('An error occurred while fetching the board lists. Please try again.')); }); - } - }, - methods: { - ...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(); + this.resetEpics(); + this.resetIssues(); + this.fetchEpicsSwimlanes({}); + } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) { + this.fetchLists(); + this.resetIssues(); } }, updateDetailIssue(newIssue, multiSelect = false) { const { sidebarInfoEndpoint } = newIssue; if (sidebarInfoEndpoint && newIssue.subscribed === undefined) { newIssue.setFetchingState('subscriptions', true); - setWeigthFetchingState(newIssue, true); + setWeightFetchingState(newIssue, true); setEpicFetchingState(newIssue, true); boardsStore .getIssueInfo(sidebarInfoEndpoint) @@ -201,7 +216,7 @@ export default () => { } = convertObjectPropsToCamelCase(data); newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); setEpicFetchingState(newIssue, false); newIssue.updateData({ humanTimeSpent: humanTotalTimeSpent, @@ -216,7 +231,7 @@ export default () => { }) .catch(() => { newIssue.setFetchingState('subscriptions', false); - setWeigthFetchingState(newIssue, false); + setWeightFetchingState(newIssue, false); Flash(__('An error occurred while fetching sidebar data')); }); } @@ -300,63 +315,32 @@ export default () => { } return !this.store.lists.filter(list => !list.preset).length; }, - tooltipTitle() { - if (this.disabled) { - return __('Please add a list to your board first'); - } - - return ''; - }, - }, - watch: { - disabled() { - this.updateTooltip(); - }, - }, - mounted() { - this.updateTooltip(); }, methods: { - updateTooltip() { - const $tooltip = $(this.$refs.addIssuesButton); - - this.$nextTick(() => { - if (this.disabled) { - $tooltip.tooltip(); - } else { - $tooltip.tooltip('dispose'); - } - }); - }, openModal() { if (!this.disabled) { this.toggleModal(true); } }, }, - template: ` - <div class="board-extra-actions"> - <button - 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" - :aria-disabled="disabled" - v-if="canAdminList" - @click="openModal"> - Add issues - </button> - </div> - `, + render(createElement) { + return createElement(BoardExtraActions, { + props: { + canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), + openModal: this.openModal, + disabled: this.disabled, + }, + }); + }, }); } toggleFocusMode(ModalStore, boardsStore); toggleLabels(); - toggleEpicsSwimlanes(); + + if (gon.features?.swimlanes) { + toggleEpicsSwimlanes(); + } + mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/mixins/is_wip_limits.js b/app/assets/javascripts/boards/mixins/is_wip_limits.js deleted file mode 100644 index f172179d3c7..00000000000 --- a/app/assets/javascripts/boards/mixins/is_wip_limits.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - computed: { - isWipLimitsOn() { - return false; - }, - }, -}; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 2f6caffbf84..09f5d5b4dd8 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,4 +1,4 @@ -/* eslint-disable no-underscore-dangle, class-methods-use-this */ +/* eslint-disable class-methods-use-this */ import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; @@ -34,7 +34,6 @@ const TYPES = { class List { constructor(obj) { this.id = obj.id; - this._uid = this.guid(); this.position = obj.position; this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title; this.type = obj.list_type || obj.listType; diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/queries/board.mutation.graphql new file mode 100644 index 00000000000..ef2b81a7939 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board.mutation.graphql @@ -0,0 +1,11 @@ +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/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql index dcfe69222a0..48420b349ae 100644 --- a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql @@ -1,7 +1,21 @@ -#import "./board_list.fragment.graphql" +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" -mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean) { - boardListCreate(input: { boardId: $boardId, backlog: $backlog }) { +mutation CreateBoardList( + $boardId: BoardID! + $backlog: Boolean + $labelId: LabelID + $milestoneId: MilestoneID + $assigneeId: UserID +) { + boardListCreate( + input: { + boardId: $boardId + backlog: $backlog + labelId: $labelId + milestoneId: $milestoneId + assigneeId: $assigneeId + } + ) { list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/queries/board_lists.query.graphql new file mode 100644 index 00000000000..88425e9a9c1 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_lists.query.graphql @@ -0,0 +1,28 @@ +#import "ee_else_ce/boards/queries/board_list.fragment.graphql" + +query ListIssues( + $fullPath: ID! + $boardId: ID! + $filters: BoardIssueInput + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + board(id: $boardId) { + lists(issueFilters: $filters) { + nodes { + ...BoardListFragment + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql deleted file mode 100644 index cb42cb3f73d..00000000000 --- a/app/assets/javascripts/boards/queries/group_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query GroupBoard($fullPath: ID!, $boardId: ID!) { - group(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql new file mode 100644 index 00000000000..3c5f4b3e3bd --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql @@ -0,0 +1,15 @@ +mutation issueSetLabels($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + labels { + nodes { + id + title + color + description + } + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/queries/lists_issues.query.graphql index c66cdf68cf4..5dbfe4675c6 100644 --- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql +++ b/app/assets/javascripts/boards/queries/lists_issues.query.graphql @@ -7,15 +7,24 @@ query ListIssues( $filters: BoardIssueInput $isGroup: Boolean = false $isProject: Boolean = false + $after: String + $first: Int ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } @@ -27,9 +36,16 @@ query ListIssues( lists(id: $id) { nodes { id - issues(filters: $filters) { - nodes { - ...IssueNode + issues(first: $first, filters: $filters, after: $after) { + count + edges { + node { + ...IssueNode + } + } + pageInfo { + endCursor + hasNextPage } } } diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql deleted file mode 100644 index 4620a7e0fd5..00000000000 --- a/app/assets/javascripts/boards/queries/project_board.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#import "ee_else_ce/boards/queries/board_list.fragment.graphql" - -query ProjectBoard($fullPath: ID!, $boardId: ID!) { - project(fullPath: $fullPath) { - board(id: $boardId) { - lists { - nodes { - ...BoardListFragment - } - } - } - } -} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 4b81d9c73ef..1fed1228106 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,28 +1,38 @@ import Cookies from 'js-cookie'; -import { sortBy, pick } from 'lodash'; -import createFlash from '~/flash'; +import { pick } from 'lodash'; + +import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql'; import { __ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; -import createDefaultClient from '~/lib/graphql'; +import createGqClient, { fetchPolicies } from '~/lib/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 { + formatBoardLists, + formatListIssues, + fullBoardId, + formatListsPageInfo, +} from '../boards_util'; import boardStore from '~/boards/stores/boards_store'; 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'; +import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('Not implemented!'); }; -export const gqlClient = createDefaultClient(); +export const gqlClient = createGqClient( + {}, + { + fetchPolicy: fetchPolicies.NO_CACHE, + }, +); export default { setInitialBoardData: ({ commit }, data) => { @@ -50,62 +60,46 @@ export default { }, fetchLists: ({ commit, state, dispatch }) => { - const { endpoints, boardType } = state; + const { endpoints, boardType, filterParams } = 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), + filters: filterParams, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, }; return gqlClient .query({ - query, + query: boardListsQuery, 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)) { + 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) { dispatch('createList', { backlog: true }); } dispatch('showWelcomeList'); }) - .catch(() => { - createFlash( - __('An error occurred while fetching the board lists. Please reload the page.'), - ); - }); + .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); }, - // 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 }) => { + createList: ({ state, commit, dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { const { boardId } = state.endpoints; + gqlClient .mutate({ mutation: createBoardListMutation, variables: { boardId: fullBoardId(boardId), backlog, + labelId, + milestoneId, + assigneeId, }, }) .then(({ data }) => { @@ -116,16 +110,15 @@ export default { dispatch('addList', list); } }) - .catch(() => { - commit(types.CREATE_LIST_FAILURE); - }); + .catch(() => commit(types.CREATE_LIST_FAILURE)); }, - addList: ({ state, commit }, list) => { - const lists = state.boardLists; + addList: ({ commit }, list) => { // Temporarily using positioning logic from boardStore - lists.push(boardStore.updateListPosition({ ...list, doNotFetchIssues: true })); - commit(types.RECEIVE_BOARD_LISTS_SUCCESS, sortBy(lists, 'position')); + commit( + types.RECEIVE_ADD_LIST_SUCCESS, + boardStore.updateListPosition({ ...list, doNotFetchIssues: true }), + ); }, showWelcomeList: ({ state, dispatch }) => { @@ -133,7 +126,9 @@ export default { return; } if ( - state.boardLists.find(list => list.type !== ListType.backlog && list.type !== ListType.closed) + Object.entries(state.boardLists).find( + ([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed, + ) ) { return; } @@ -155,13 +150,16 @@ export default { notImplemented(); }, - moveList: ({ state, commit, dispatch }, { listId, newIndex, adjustmentValue }) => { + moveList: ( + { state, commit, dispatch }, + { listId, replacedListId, newIndex, adjustmentValue }, + ) => { const { boardLists } = state; - const backupList = [...boardLists]; - const movedList = boardLists.find(({ id }) => id === listId); + const backupList = { ...boardLists }; + const movedList = boardLists[listId]; const newPosition = newIndex - 1; - const listAtNewIndex = boardLists[newIndex]; + const listAtNewIndex = boardLists[replacedListId]; movedList.position = newPosition; listAtNewIndex.position += adjustmentValue; @@ -197,7 +195,9 @@ export default { notImplemented(); }, - fetchIssuesForList: ({ state, commit }, listId) => { + fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + const { endpoints, boardType, filterParams } = state; const { fullPath, boardId } = endpoints; @@ -208,6 +208,8 @@ export default { filters: filterParams, isGroup: boardType === BoardType.group, isProject: boardType === BoardType.project, + first: 20, + after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined, }; return gqlClient @@ -221,36 +223,14 @@ export default { .then(({ data }) => { const { lists } = data[boardType]?.board; const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listId }); + const listPageInfo = formatListsPageInfo(lists); + commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); }) .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); }, - fetchIssuesForAllLists: ({ state, commit }) => { - commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); - - const { endpoints, boardType, filterParams } = state; - const { fullPath, boardId } = endpoints; - - const variables = { - fullPath, - boardId: fullBoardId(boardId), - filters: filterParams, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, - }; - - return gqlClient - .query({ - query: listsIssuesQuery, - variables, - }) - .then(({ data }) => { - const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); - commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues); - }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); + resetIssues: ({ commit }) => { + commit(types.RESET_ISSUES); }, moveIssue: ( @@ -303,6 +283,31 @@ export default { commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); }, + setActiveIssueLabels: async ({ commit, getters }, input) => { + const activeIssue = getters.getActiveIssue; + const { data } = await gqlClient.mutate({ + mutation: issueSetLabels, + variables: { + input: { + iid: String(activeIssue.iid), + addLabelIds: input.addLabelIds ?? [], + removeLabelIds: input.removeLabelIds ?? [], + projectPath: input.projectPath, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'labels', + value: data.updateIssue.issue.labels.nodes, + }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index faf4f9ebfd3..d1a5db1bcc5 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -2,7 +2,7 @@ /* global List */ /* global ListIssue */ import $ from 'jquery'; -import { sortBy } from 'lodash'; +import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; @@ -12,7 +12,7 @@ import { parseBoolean, convertObjectPropsToCamelCase, } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -23,7 +23,11 @@ import ListLabel from '../models/label'; import ListAssignee from '../models/assignee'; import ListMilestone from '../models/milestone'; +import createBoardMutation from '../queries/board.mutation.graphql'; + const PER_PAGE = 20; +export const gqlClient = createDefaultClient(); + const boardsStore = { disabled: false, timeTracking: { @@ -114,7 +118,6 @@ const boardsStore = { .catch(() => { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); - this.removeBlankState(); }, updateNewListDropdown(listId) { $(`.js-board-list-${listId}`).removeClass('is-active'); @@ -124,22 +127,14 @@ const boardsStore = { return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0]; }, addBlankState() { - if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - - this.addList({ - id: 'blank', - list_type: 'blank', - title: __('Welcome to your Issue Board!'), - position: 0, - }); - }, - removeBlankState() { - this.removeList('blank'); + if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return; - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '', - }); + this.generateDefaultLists() + .then(res => res.data) + .then(data => Promise.all(data.map(list => this.addList(list)))) + .catch(() => { + this.removeList(undefined, 'label'); + }); }, findIssueLabel(issue, findLabel) { @@ -542,6 +537,10 @@ const boardsStore = { this.timeTracking.limitToHours = parseBoolean(limitToHours); }, + generateBoardGid(boardId) { + return `gid://gitlab/Board/${boardId}`; + }, + generateBoardsPath(id) { return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`; }, @@ -800,9 +799,33 @@ const boardsStore = { } if (boardPayload.id) { - return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }); + const input = { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(boardPayload.id), + }; + + return Promise.all([ + axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }), + gqlClient.mutate({ + mutation: createBoardMutation, + variables: input, + }), + ]); } - return axios.post(this.generateBoardsPath(), { board: boardPayload }); + + return axios + .post(this.generateBoardsPath(), { board: boardPayload }) + .then(resp => resp.data) + .then(data => { + gqlClient.mutate({ + mutation: createBoardMutation, + variables: { + ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']), + id: this.generateBoardGid(data.id), + }, + }); + return data; + }); }, deleteBoard({ id }) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 3688476dc5f..89a3b14b262 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,10 +1,11 @@ +import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { - if (!gon?.features?.boardsWithSwimlanes) { + if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { return false; } @@ -22,4 +23,16 @@ export default { getActiveIssue: state => { return state.issues[state.activeId] || {}; }, + + getListByLabelId: state => labelId => { + return find(state.boardLists, l => l.label?.id === labelId); + }, + + getListByTitle: state => title => { + return find(state.boardLists, l => l.title === title); + }, + + shouldUseGraphQL: () => { + return gon?.features?.graphqlBoardLists; + }, }; diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index f0a283f6161..09ab08062df 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -3,6 +3,7 @@ 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 RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; 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'; @@ -12,11 +13,9 @@ 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 REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; 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'; @@ -32,3 +31,4 @@ 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'; +export const RESET_ISSUES = 'RESET_ISSUES'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index faeb3e25a71..0c7dbc0d2ef 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,8 +1,8 @@ import Vue from 'vue'; -import { sortBy, pull } from 'lodash'; +import { pull, union } from 'lodash'; import { formatIssue, moveIssueListHelper } from '../boards_util'; import * as mutationTypes from './mutation_types'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; const notImplemented = () => { @@ -10,11 +10,13 @@ const notImplemented = () => { throw new Error('Not implemented!'); }; -const removeIssueFromList = (state, listId, issueId) => { +export const removeIssueFromList = ({ state, listId, issueId }) => { Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); + const list = state.boardLists[listId]; + Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 }); }; -const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { +export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { const listIssues = state.issuesByListId[listId]; let newIndex = atIndex || 0; if (moveBeforeId) { @@ -24,6 +26,8 @@ const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atI } listIssues.splice(newIndex, 0, issueId); Vue.set(state.issuesByListId, listId, listIssues); + const list = state.boardLists[listId]; + Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 }); }; export default { @@ -39,6 +43,12 @@ export default { state.boardLists = lists; }, + [mutationTypes.RECEIVE_BOARD_LISTS_FAILURE]: state => { + state.error = s__( + 'Boards|An error occurred while fetching the board lists. Please reload the page.', + ); + }, + [mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) { state.activeId = id; state.sidebarType = sidebarType; @@ -49,15 +59,15 @@ export default { }, [mutationTypes.CREATE_LIST_FAILURE]: state => { - state.error = __('An error occurred while creating the list. Please try again.'); + state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, [mutationTypes.REQUEST_ADD_LIST]: () => { notImplemented(); }, - [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => { - notImplemented(); + [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => { + Vue.set(state.boardLists, list.id, list); }, [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => { @@ -66,14 +76,12 @@ export default { [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')); + Vue.set(boardLists, movedList.id, movedList); + Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex); }, [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { - state.error = __('An error occurred while updating the list. Please try again.'); + state.error = s__('Boards|An error occurred while updating the list. Please try again.'); Vue.set(state, 'boardLists', backupList); }, @@ -89,28 +97,36 @@ export default { notImplemented(); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: (state, { listIssues, listId }) => { + [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( + state, + { listIssues, listPageInfo, 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); + Vue.set( + state.issuesByListId, + listId, + union(state.issuesByListId[listId] || [], listData[listId]), + ); + Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: 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; + state.error = s__( + 'Boards|An error occurred while fetching the board issues. Please reload the page.', + ); + Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, { listData, issues }) => { - state.issuesByListId = listData; - state.issues = issues; - state.isLoadingIssues = false; + [mutationTypes.RESET_ISSUES]: state => { + Object.keys(state.issuesByListId).forEach(listId => { + Vue.set(state.issuesByListId, listId, []); + }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { @@ -122,11 +138,6 @@ export default { Vue.set(state.issues[issueId], prop, value); }, - [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => { - state.error = __('An error occurred while fetching the board issues. Please reload the page.'); - state.isLoadingIssues = false; - }, - [mutationTypes.REQUEST_ADD_ISSUE]: () => { notImplemented(); }, @@ -143,13 +154,13 @@ export default { 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 fromList = state.boardLists[fromListId]; + const toList = state.boardLists[toListId]; const issue = moveIssueListHelper(originalIssue, fromList, toList); Vue.set(state.issues, issue.id, issue); - removeIssueFromList(state, fromListId, issue.id); + removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); }, @@ -162,9 +173,9 @@ export default { state, { originalIssue, fromListId, toListId, originalIndex }, ) => { - state.error = __('An error occurred while moving the issue. Please try again.'); + state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); Vue.set(state.issues, originalIssue.id, originalIssue); - removeIssueFromList(state, toListId, originalIssue.id); + removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); addIssueToList({ state, listId: fromListId, @@ -193,8 +204,8 @@ export default { }, [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); + state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + removeIssueFromList({ state, listId: list.id, issueId: issue.id }); }, [mutationTypes.SET_CURRENT_PAGE]: () => { diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index be937d68c6c..b91c09f8051 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -8,10 +8,11 @@ export default () => ({ isShowingLabels: true, activeId: inactiveId, sidebarType: '', - boardLists: [], + boardLists: {}, + listsFlags: {}, issuesByListId: {}, + pageInfoByListId: {}, issues: {}, - isLoadingIssues: false, filterParams: {}, error: undefined, // TODO: remove after ce/ee split of board_content.vue diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index e60e7059192..fa13d3a9e3c 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,13 +1,15 @@ import $ from 'jquery'; import Vue from 'vue'; -import collapseIcon from './icons/fullscreen_collapse.svg'; -import expandIcon from './icons/fullscreen_expand.svg'; +import { GlIcon } from '@gitlab/ui'; export default (ModalStore, boardsStore) => { const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); return new Vue({ el: document.getElementById('js-toggle-focus-btn'), + components: { + GlIcon, + }, data: { modal: ModalStore.store, store: boardsStore.state, @@ -32,12 +34,7 @@ export default (ModalStore, boardsStore) => { title="Toggle focus mode" ref="toggleFocusModeButton" @click="toggleFocusMode"> - <span v-show="isFullscreen"> - ${collapseIcon} - </span> - <span v-show="!isFullscreen"> - ${expandIcon} - </span> + <gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" /> </a> </div> `, |