diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
commit | 7e9c479f7de77702622631cff2628a9c8dcbc627 (patch) | |
tree | c8f718a08e110ad7e1894510980d2155a6549197 /app/assets/javascripts/boards | |
parent | e852b0ae16db4052c1c567d9efa4facc81146e88 (diff) |
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
38 files changed, 1362 insertions, 206 deletions
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue new file mode 100644 index 00000000000..c81f171af2b --- /dev/null +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -0,0 +1,178 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; +import searchUsers from '~/boards/queries/users_search.query.graphql'; + +export default { + noSearchDelay: 0, + searchDelay: 250, + i18n: { + unassigned: __('Unassigned'), + assignee: __('Assignee'), + assignees: __('Assignees'), + assignTo: __('Assign to'), + }, + components: { + BoardEditableItem, + IssuableAssignees, + MultiSelectDropdown, + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, + }, + data() { + return { + search: '', + participants: [], + selected: this.$store.getters.activeIssue.assignees, + }; + }, + apollo: { + participants: { + query() { + return this.isSearchEmpty ? getIssueParticipants : searchUsers; + }, + variables() { + if (this.isSearchEmpty) { + return { + id: `gid://gitlab/Issue/${this.activeIssue.iid}`, + }; + } + + return { + search: this.search, + }; + }, + update(data) { + if (this.isSearchEmpty) { + return data.issue?.participants?.nodes || []; + } + + return data.users?.nodes || []; + }, + debounce() { + const { noSearchDelay, searchDelay } = this.$options; + + return this.isSearchEmpty ? noSearchDelay : searchDelay; + }, + }, + }, + computed: { + ...mapGetters(['activeIssue']), + assigneeText() { + return n__('Assignee', '%d Assignees', this.selected.length); + }, + unSelectedFiltered() { + return this.participants.filter(({ username }) => { + return !this.selectedUserNames.includes(username); + }); + }, + selectedIsEmpty() { + return this.selected.length === 0; + }, + selectedUserNames() { + return this.selected.map(({ username }) => username); + }, + isSearchEmpty() { + return this.search === ''; + }, + }, + methods: { + ...mapActions(['setAssignees']), + clearSelected() { + this.selected = []; + }, + selectAssignee(name) { + if (name === undefined) { + this.clearSelected(); + return; + } + + this.selected = this.selected.concat(name); + }, + unselect(name) { + this.selected = this.selected.filter(user => user.username !== name); + }, + saveAssignees() { + this.setAssignees(this.selectedUserNames); + }, + isChecked(id) { + return this.selectedUserNames.includes(id); + }, + }, +}; +</script> + +<template> + <board-editable-item :title="assigneeText" @close="saveAssignees"> + <template #collapsed> + <issuable-assignees :users="activeIssue.assignees" /> + </template> + + <template #default> + <multi-select-dropdown + class="w-100" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + > + <template #search> + <gl-search-box-by-type v-model.trim="search" /> + </template> + <template #items> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + data-testid="unassign" + class="mt-2" + @click="selectAssignee()" + >{{ $options.i18n.unassigned }}</gl-dropdown-item + > + <gl-dropdown-divider data-testid="unassign-divider" /> + <gl-dropdown-item + v-for="item in selected" + :key="item.id" + :is-checked="isChecked(item.username)" + @click="unselect(item.username)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="item.name" + :sub-label="item.username" + :src="item.avatarUrl || item.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> + <gl-dropdown-item + v-for="unselectedUser in unSelectedFiltered" + :key="unselectedUser.id" + :data-testid="`item_${unselectedUser.name}`" + @click="selectAssignee(unselectedUser)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="unselectedUser.name" + :sub-label="unselectedUser.username" + :src="unselectedUser.avatarUrl || unselectedUser.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + </template> + </multi-select-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index 072dd87861a..f796acd2303 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -44,9 +44,6 @@ export default { multiSelectVisible() { return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; }, - canMultiSelect() { - return gon.features && gon.features.multiSelectBoard; - }, }, methods: { mouseDown() { @@ -59,7 +56,7 @@ export default { // 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); + const isMultiSelect = e.ctrlKey || e.metaKey; if (this.showDetail || isMultiSelect) { this.showDetail = false; diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 9295065b7b7..cb93340bcf8 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,14 +1,10 @@ <script> -import { mapGetters, mapActions } from 'vuex'; +// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards import Sortable from 'sortablejs'; 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 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'; import { ListType } from '../constants'; @@ -16,12 +12,8 @@ export default { components: { BoardPromotionState: EmptyComponent, BoardListHeader, - BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, + BoardList, }, - directives: { - Tooltip, - }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -50,44 +42,25 @@ export default { }; }, computed: { - ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { return this.list.type !== ListType.promotion; }, - uniqueKey() { - // 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; + return this.list.issues; }, }, watch: { filter: { handler() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } else { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); - } + this.list.page = 1; + this.list.getIssues(true).catch(() => { + // TODO: handle request error + }); }, deep: true, }, }, mounted() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } - const instance = this; const sortableOptions = getBoardSortableDefaultOptions({ @@ -113,12 +86,6 @@ export default { Sortable.create(this.$el.parentNode, sortableOptions); }, - methods: { - ...mapActions(['fetchIssuesForList']), - showListNewIssueForm(listId) { - eventHub.$emit('showForm', listId); - }, - }, }; </script> @@ -131,7 +98,7 @@ export default { 'board-type-assignee': list.type === 'assignee', }" :data-id="list.id" - class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" data-qa-selector="board_list" > <div diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue new file mode 100644 index 00000000000..8a59355eb83 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -0,0 +1,94 @@ +<script> +import { mapGetters, mapActions, mapState } from 'vuex'; +import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; +import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state'; +import BoardList from './board_list_new.vue'; +import { ListType } from '../constants'; + +export default { + components: { + BoardPromotionState, + BoardListHeader, + BoardList, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + }, + computed: { + ...mapState(['filterParams']), + ...mapGetters(['getIssuesByList']), + showBoardListAndBoardInfo() { + return this.list.type !== ListType.promotion; + }, + listIssues() { + return this.getIssuesByList(this.list.id); + }, + shouldFetchIssues() { + return this.list.type !== ListType.blank; + }, + }, + watch: { + filterParams: { + handler() { + if (this.shouldFetchIssues) { + this.fetchIssuesForList({ listId: this.list.id }); + } + }, + deep: true, + immediate: true, + }, + }, + methods: { + ...mapActions(['fetchIssuesForList']), + // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515 + }, +}; +</script> + +<template> + <div + :class="{ + 'is-draggable': !list.preset, + 'is-expandable': list.isExpandable, + 'is-collapsed': !list.isExpanded, + 'board-type-assignee': list.type === 'assignee', + }" + :data-id="list.id" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + data-qa-selector="board_list" + > + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + > + <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + <board-list + v-if="showBoardListAndBoardInfo" + ref="board-list" + :disabled="disabled" + :issues="listIssues" + :list="list" + /> + + <!-- Will be only available in EE --> + <board-promotion-state v-if="list.id === 'promotion'" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index ad3d653b905..754b00b54e0 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -43,7 +43,7 @@ export default { <template> <div class="append-bottom-20"> - <label class="form-section-title label-bold" for="board-new-name"> + <label class="label-bold gl-font-lg" for="board-new-name"> {{ __('List options') }} </label> <p class="text-secondary gl-mb-3"> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 2515f471379..92976574efb 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,13 +1,14 @@ <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 BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import BoardColumnNew from './board_column_new.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - BoardColumn, + BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -38,12 +39,11 @@ export default { }, mounted() { if (this.glFeatures.graphqlBoardLists) { - this.fetchLists(); this.showPromotionList(); } }, methods: { - ...mapActions(['fetchLists', 'showPromotionList']), + ...mapActions(['showPromotionList']), }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 793c594cf16..e4ef3600ff9 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -196,9 +196,7 @@ export default { <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> <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">{{ - __('Title') - }}</label> + <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label> <input id="board-new-name" ref="name" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index d01df44e7e4..53989e2d9de 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -16,9 +16,7 @@ import { // 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()); -} +Sortable.mount(new MultiDrag()); export default { name: 'BoardList', @@ -100,12 +98,11 @@ export default { 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; - multiSelectOpts.selectedClass = 'js-multi-select'; - multiSelectOpts.animation = 500; - } + const multiSelectOpts = { + multiDrag: true, + selectedClass: 'js-multi-select', + animation: 500, + }; const options = getBoardSortableDefaultOptions({ scroll: true, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bb9a1b79d91..d85ba2038a7 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -17,7 +17,6 @@ import eventHub from '../eventhub'; 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: { @@ -32,7 +31,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -121,12 +119,9 @@ export default { collapsedTooltipTitle() { return this.listTitle || this.listAssignee; }, - shouldDisplaySwimlanes() { - return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; - }, }, methods: { - ...mapActions(['updateList', 'setActiveId']), + ...mapActions(['setActiveId']), openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -160,11 +155,7 @@ export default { } }, updateListFunction() { - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); - } else { - this.list.update(); - } + this.list.update(); }, }, }; @@ -188,8 +179,9 @@ export default { 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, }" - class="board-title gl-m-0 gl-display-flex js-board-handle" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" > <gl-button v-if="list.isExpandable" @@ -202,7 +194,15 @@ export default { @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> - <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon"> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > <gl-icon name="timer" /> </span> @@ -210,6 +210,9 @@ export default { v-if="showAssigneeListDetails" :href="list.assignee.path" class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" > <img v-gl-tooltip.hover.bottom @@ -223,20 +226,28 @@ export default { </a> <div class="board-title-text" - :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" > <span v-if="list.type !== 'label'" v-gl-tooltip.hover :class="{ - 'gl-display-inline-block': list.type === 'milestone', + 'gl-display-block': !list.isExpanded || list.type === 'milestone', }" :title="listTitle" - class="board-title-main-text block-truncated" + class="board-title-main-text gl-text-truncate" > {{ list.title }} </span> - <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> + <span + v-if="list.type === 'assignee'" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + :class="{ 'gl-display-none': !list.isExpanded }" + > @{{ listAssignee }} </span> <gl-label @@ -279,7 +290,10 @@ export default { <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" - :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" > <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue new file mode 100644 index 00000000000..99347a4cd4d --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -0,0 +1,358 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlButton, + GlButtonGroup, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { n__, s__ } from '~/locale'; +import AccessorUtilities from '../../lib/utils/accessor'; +import IssueCount from './issue_count.vue'; +import eventHub from '../eventhub'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId, LIST, ListType } from '../constants'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlButtonGroup, + GlButton, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + IssueCount, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + isSwimlanesHeader: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + weightFeatureAvailable: { + default: false, + }, + scopedLabelsAvailable: { + default: false, + }, + currentUserId: { + default: null, + }, + }, + computed: { + ...mapState(['activeId']), + isLoggedIn() { + return Boolean(this.currentUserId); + }, + listType() { + return this.list.type; + }, + listAssignee() { + return this.list?.assignee?.username || ''; + }, + listTitle() { + return this.list?.label?.description || this.list.title || ''; + }, + showListHeaderButton() { + return ( + !this.disabled && + this.listType !== ListType.closed && + this.listType !== ListType.blank && + this.listType !== ListType.promotion + ); + }, + showMilestoneListDetails() { + return ( + this.list.type === ListType.milestone && + this.list.milestone && + (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + showAssigneeListDetails() { + return ( + this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + issuesCount() { + return this.list.issuesSize; + }, + issuesTooltipLabel() { + return n__(`%d issue`, `%d issues`, this.issuesCount); + }, + chevronTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, + chevronIcon() { + return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; + }, + isNewIssueShown() { + return this.listType === ListType.backlog || this.showListHeaderButton; + }, + isSettingsShown() { + return ( + this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded + ); + }, + showBoardListAndBoardInfo() { + return this.listType !== ListType.blank && this.listType !== ListType.promotion; + }, + uniqueKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `boards.${this.boardId}.${this.listType}.${this.list.id}`; + }, + collapsedTooltipTitle() { + return this.listTitle || this.listAssignee; + }, + headerStyle() { + return { borderTopColor: this.list?.label?.color }; + }, + }, + methods: { + ...mapActions(['updateList', 'setActiveId']), + openSidebarSettings() { + if (this.activeId === inactiveId) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + }, + showScopedLabels(label) { + return this.scopedLabelsAvailable && isScopedLabel(label); + }, + + showNewIssueForm() { + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + toggleExpanded() { + this.list.isExpanded = !this.list.isExpanded; + + 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'); + }, + addToLocalStorage() { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + } + }, + updateListFunction() { + this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); + }, + }, +}; +</script> + +<template> + <header + :class="{ + 'has-border': list.label && list.label.color, + 'gl-h-full': !list.isExpanded, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, + }" + :style="headerStyle" + class="board-header gl-relative" + data-qa-selector="board_list_header" + data-testid="board-list-header" + > + <h3 + :class="{ + 'user-can-drag': !disabled && !list.preset, + 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, + 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, + 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, + }" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" + > + <gl-button + v-if="list.isExpandable" + v-gl-tooltip.hover + :aria-label="chevronTooltip" + :title="chevronTooltip" + :icon="chevronIcon" + class="board-title-caret no-drag gl-cursor-pointer" + variant="link" + @click="toggleExpanded" + /> + <!-- EE start --> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > + <gl-icon name="timer" /> + </span> + + <a + v-if="showAssigneeListDetails" + :href="list.assignee.path" + class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" + > + <img + v-gl-tooltip.hover.bottom + :title="listAssignee" + :alt="list.assignee.name" + :src="list.assignee.avatar" + class="avatar s20" + height="20" + width="20" + /> + </a> + <!-- EE end --> + <div + class="board-title-text" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" + > + <!-- EE start --> + <span + v-if="listType !== 'label'" + v-gl-tooltip.hover + :class="{ + 'gl-display-block': !list.isExpanded || listType === 'milestone', + }" + :title="listTitle" + class="board-title-main-text gl-text-truncate" + > + {{ list.title }} + </span> + <span + v-if="listType === 'assignee'" + v-show="list.isExpanded" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + > + @{{ listAssignee }} + </span> + <!-- EE end --> + <gl-label + v-if="listType === 'label'" + v-gl-tooltip.hover.bottom + :background-color="list.label.color" + :description="list.label.description" + :scoped="showScopedLabels(list.label)" + :size="!list.isExpanded ? 'sm' : ''" + :title="list.label.title" + /> + </div> + + <!-- EE start --> + <span + v-if="isSwimlanesHeader && !list.isExpanded" + ref="collapsedInfo" + aria-hidden="true" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" + > + <gl-icon name="information" /> + </span> + <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> + <div v-if="list.maxIssueCount !== 0"> + • + <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> + <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #maxIssueCount>{{ list.maxIssueCount }}</template> + </gl-sprintf> + </div> + <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-if="weightFeatureAvailable"> + • + <gl-sprintf :message="__('%{totalWeight} total weight')"> + <template #totalWeight>{{ list.totalWeight }}</template> + </gl-sprintf> + </div> + </gl-tooltip> + <!-- EE end --> + + <div + v-if="showBoardListAndBoardInfo" + class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" + > + <span class="gl-display-inline-flex"> + <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="issuesCount" :max-issue-count="list.maxIssueCount" /> + </span> + <!-- EE start --> + <template v-if="weightFeatureAvailable"> + <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> + <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> + <gl-icon class="gl-mr-2" name="weight" /> + {{ list.totalWeight }} + </span> + </template> + <!-- EE end --> + </span> + </div> + <gl-button-group + v-if="isNewIssueShown || isSettingsShown" + class="board-list-button-group pl-2" + > + <gl-button + v-if="isNewIssueShown" + v-show="list.isExpanded" + ref="newIssueBtn" + v-gl-tooltip.hover + :aria-label="__('New issue')" + :title="__('New issue')" + class="issue-count-badge-add-button no-drag" + icon="plus" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="__('List settings')" + class="no-drag js-board-settings-button" + :title="__('List settings')" + icon="settings" + @click="openSidebarSettings" + /> + <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> + </gl-button-group> + </h3> + </header> +</template> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 0a495d05122..396aedcc557 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import BoardNewIssue from './board_new_issue.vue'; +import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 0a665b82880..a9e6d768656 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,4 @@ <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'; @@ -9,6 +7,8 @@ import ProjectSelect from './project_select.vue'; import boardsStore from '../stores/boards_store'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards + export default { name: 'BoardNewIssue', components: { @@ -31,23 +31,18 @@ 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(); @@ -74,31 +69,14 @@ 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(); - - if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) { - boardsStore.setIssueDetail(issue); - boardsStore.setListDetail(this.list); - } + 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 - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.addListIssueFailure({ list: this.list, issue }); - } else { - this.list.removeIssue(issue); - } + this.list.removeIssue(issue); // Show error message this.error = true; @@ -137,7 +115,7 @@ export default { <gl-button ref="submitButton" :disabled="disabled" - class="float-left" + class="float-left js-no-auto-disable" variant="success" category="primary" type="submit" diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue new file mode 100644 index 00000000000..969c84ddb59 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue @@ -0,0 +1,129 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import eventHub from '../eventhub'; +import ProjectSelect from './project_select.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; + +export default { + name: 'BoardNewIssue', + i18n: { + submit: __('Submit issue'), + cancel: __('Cancel'), + }, + components: { + ProjectSelect, + GlButton, + }, + mixins: [glFeatureFlagMixin()], + props: { + list: { + type: Object, + required: true, + }, + }, + inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], + data() { + return { + title: '', + selectedProject: {}, + }; + }, + computed: { + disabled() { + if (this.groupId) { + return this.title === '' || !this.selectedProject.name; + } + return this.title === ''; + }, + inputFieldId() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.list.id}-title`; + }, + }, + mounted() { + this.$refs.input.focus(); + eventHub.$on('setSelectedProject', this.setSelectedProject); + }, + methods: { + ...mapActions(['addListNewIssue']), + submit(e) { + e.preventDefault(); + + const labels = this.list.label ? [this.list.label] : []; + const assignees = this.list.assignee ? [this.list.assignee] : []; + const milestone = getMilestone(this.list); + + const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; + + const { title } = this; + + eventHub.$emit(`scroll-board-list-${this.list.id}`); + + return this.addListNewIssue({ + issueInput: { + title, + labelIds: labels?.map(l => l.id), + assigneeIds: assignees?.map(a => a?.id), + milestoneId: milestone?.id, + projectPath: this.selectedProject.path, + weight: weight >= 0 ? weight : null, + }, + list: this.list, + }).then(() => { + this.reset(); + }); + }, + reset() { + this.title = ''; + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + setSelectedProject(selectedProject) { + this.selectedProject = selectedProject; + }, + }, +}; +</script> + +<template> + <div class="board-new-issue-form"> + <div class="board-card position-relative p-3 rounded"> + <form ref="submitForm" @submit="submit"> + <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> + <input + :id="inputFieldId" + ref="input" + v-model="title" + class="form-control" + type="text" + name="issue_title" + autocomplete="off" + /> + <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <div class="clearfix gl-mt-3"> + <gl-button + ref="submitButton" + :disabled="disabled" + class="float-left js-no-auto-disable" + variant="success" + category="primary" + type="submit" + > + {{ $options.i18n.submit }} + </gl-button> + <gl-button + ref="cancelButton" + class="float-right" + type="button" + variant="default" + @click="reset" + > + {{ $options.i18n.cancel }} + </gl-button> + </div> + </form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_promotion_state.js @@ -0,0 +1 @@ +export default {}; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 392e056dcbf..80070b25bd0 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -36,6 +36,9 @@ export default { computed: { ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapState(['activeId', 'sidebarType', 'boardLists']), + isWipLimitsOn() { + return this.glFeatures.wipLimits; + }, activeList() { /* Warning: Though a computed property it is not reactive because we are @@ -66,14 +69,18 @@ export default { eventHub.$off('sidebar.closeAll', this.unsetActiveId); }, methods: { - ...mapActions(['unsetActiveId']), + ...mapActions(['unsetActiveId', 'removeList']), 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(); + if (window.confirm(__('Are you sure you want to remove this list?'))) { + if (this.shouldUseGraphQL) { + this.removeList(this.activeId); + } else { + this.activeList.destroy(); + } this.unsetActiveId(); } }, @@ -105,7 +112,10 @@ export default { :active-list="activeList" :board-list-type="boardListType" /> - <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + <board-settings-sidebar-wip-limit + v-if="isWipLimitsOn" + :max-issue-count="activeList.maxIssueCount" + /> <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> <gl-button variant="danger" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 271e1fc4b5f..0b079c78209 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -261,7 +261,7 @@ export default { > <gl-deprecated-dropdown-item v-show="filteredBoards.length === 0" - class="no-pointer-events text-secondary" + class="gl-pointer-events-none text-secondary" > {{ s__('IssueBoards|No matching boards found') }} </gl-deprecated-dropdown-item> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a181ea51c4a..45ce1e51489 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -3,7 +3,7 @@ import { sortBy } from 'lodash'; import { mapState } from 'vuex'; import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; @@ -89,6 +89,12 @@ export default { orderedLabels() { return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); }, + blockedLabel() { + if (this.issue.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + } + return __('Blocked issue'); + }, }, methods: { isIndexLessThanlimit(index) { @@ -133,15 +139,16 @@ export default { </script> <template> <div> - <div class="d-flex board-card-header" dir="auto"> + <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> <gl-icon v-if="issue.blocked" v-gl-tooltip name="issue-block" - :title="__('Blocked issue')" + :title="blockedLabel" class="issue-blocked-icon gl-mr-2" - :aria-label="__('Blocked issue')" + :aria-label="blockedLabel" + data-testid="issue-blocked-icon" /> <gl-icon v-if="issue.confidential" @@ -156,7 +163,7 @@ export default { }}</a> </h4> </div> - <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap"> + <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <template v-for="label in orderedLabels"> <gl-label :key="label.id" @@ -169,24 +176,26 @@ export default { /> </template> </div> - <div class="board-card-footer d-flex justify-content-between align-items-end"> + <div + class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end" + > <div - class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container" + class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > <span v-if="issue.referencePath" - class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" > <tooltip-on-truncate v-if="issueReferencePath" :title="issueReferencePath" placement="bottom" - class="board-issue-path block-truncated bold" + class="board-issue-path gl-text-truncate gl-font-weight-bold" >{{ issueReferencePath }}</tooltip-on-truncate > #{{ issue.iid }} </span> - <span class="board-info-items gl-mt-3 d-inline-block"> + <span class="board-info-items gl-mt-3 gl-display-inline-block"> <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight @@ -196,20 +205,20 @@ export default { /> </span> </div> - <div class="board-card-assignee d-flex"> + <div class="board-card-assignee gl-display-flex"> <user-avatar-link v-for="(assignee, index) in issue.assignees" v-if="shouldRenderAssignee(index)" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar || assignee.avatar_url" + :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" > <span class="js-assignee-tooltip"> - <span class="bold d-block">{{ __('Assignee') }}</span> + <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> {{ assignee.name }} <span class="text-white-50">@{{ assignee.username }}</span> </span> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index cd4512f320f..eb2db260717 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,13 +1,13 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { components: { GlButton, + GlSprintf, }, mixins: [modalMixin], props: { @@ -34,11 +34,8 @@ export default { if (this.activeTab === 'selected') { obj.title = __("You haven't selected any issues yet"); - obj.content = sprintf( - __( - 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.', - ), - { startTag: '<strong>', endTag: '</strong>' }, + obj.content = __( + 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.', ); } @@ -57,7 +54,13 @@ export default { <div class="col-12 col-md-6 order-md-first"> <div class="text-content"> <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> + <p> + <gl-sprintf :message="contents.content"> + <template #tag="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> <gl-button v-if="activeTab === 'all'" :href="newIssuePath" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index c8926c5ef2a..47eee5306da 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; import { fullLabelId } from '../boards_util'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; @@ -61,7 +62,7 @@ export default function initNewListDropdown() { const active = boardsStore.findListByLabelId(label.id); const $li = $('<li />'); const $a = $('<a />', { - class: active ? `is-active js-board-list-${active.id}` : '', + class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '', text: label.title, href: '#', }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 566c0081b9d..f90fe582566 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -44,6 +44,7 @@ export default { this.selectedProject = { id: $el.data('project-id'), name: $el.data('project-name'), + path: $el.data('project-path'), }; eventHub.$emit('setSelectedProject', this.selectedProject); }, @@ -75,11 +76,12 @@ export default { renderRow(project) { return ` <li> - <a href='#' class='dropdown-menu-link' data-project-id="${ - project.id - }" data-project-name="${project.name}" data-project-name-with-namespace="${ - project.name_with_namespace - }"> + <a href='#' class='dropdown-menu-link' + data-project-id="${project.id}" + data-project-name="${project.name}" + data-project-name-with-namespace="${project.name_with_namespace}" + data-project-path="${project.path_with_namespace}" + > ${escape(project.name_with_namespace)} </a> </li> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue new file mode 100644 index 00000000000..6935ead2706 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -0,0 +1,111 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlButton, GlDatepicker } from '@gitlab/ui'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlButton, + GlDatepicker, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }), + hasDueDate() { + return this.issue.dueDate != null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.issue.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return ''; + } + + return dateInWords(this.parsedDueDate, true); + }, + }, + methods: { + ...mapActions(['setActiveIssueDueDate']), + async openDatePicker() { + await this.$nextTick(); + this.$refs.datePicker.calendar.show(); + }, + async setDueDate(date) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null; + await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue }); + } catch (e) { + createFlash({ message: this.$options.i18n.updateDueDateError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + dueDate: __('Due date'), + removeDueDate: __('remove due date'), + updateDueDateError: __('An error occurred when updating the issue due date'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + class="board-sidebar-due-date" + :title="$options.i18n.dueDate" + :loading="loading" + @open="openDatePicker" + > + <template v-if="hasDueDate" #collapsed> + <div class="gl-display-flex gl-align-items-center"> + <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong> + <span class="gl-mx-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-400!" + data-testid="reset-button" + :disabled="loading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </template> + <template> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </board-editable-item> +</template> +<style> +/* + * This can be removed after closing: + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048 + */ +.board-sidebar-due-date .gl-datepicker, +.board-sidebar-due-date .gl-datepicker-input { + width: 100%; +} +</style> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 0f063c7582e..9d537a4ef2c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,9 +21,9 @@ export default { }, inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { - ...mapGetters({ issue: 'getActiveIssue' }), + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), selectedLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, @@ -31,17 +31,13 @@ export default { })); }, issueLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, scoped: isScopedLabel(label), })); }, - projectPath() { - const { referencePath = '' } = this.issue; - return referencePath.slice(0, referencePath.indexOf('#')); - }, }, methods: { ...mapActions(['setActiveIssueLabels']), @@ -55,7 +51,7 @@ export default { .filter(label => !payload.find(selected => selected.id === label.id)) .map(label => label.id); - const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred while updating labels.') }); @@ -68,7 +64,7 @@ export default { try { const removeLabelIds = [getIdFromGraphQLId(id)]; - const input = { removeLabelIds, projectPath: this.projectPath }; + const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred when removing the label.') }); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue new file mode 100644 index 00000000000..ed069cea630 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -0,0 +1,71 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlToggle } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + i18n: { + header: { + title: __('Notifications'), + /* Any change to subscribeDisabledDescription + must be reflected in app/helpers/notifications_helper.rb */ + subscribeDisabledDescription: __( + 'Notifications have been disabled by the project or group owner', + ), + }, + updateSubscribedErrorMessage: s__( + 'IssueBoards|An error occurred while setting notifications status.', + ), + }, + components: { + GlToggle, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + notificationText() { + return this.activeIssue.emailsDisabled + ? this.$options.i18n.header.subscribeDisabledDescription + : this.$options.i18n.header.title; + }, + }, + methods: { + ...mapActions(['setActiveIssueSubscribed']), + async handleToggleSubscription() { + this.loading = true; + + try { + await this.setActiveIssueSubscribed({ + subscribed: !this.activeIssue.subscribed, + projectPath: this.projectPathForActiveIssue, + }); + } catch (error) { + createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-testid="sidebar-notifications" + > + <span data-testid="notification-header-text"> {{ notificationText }} </span> + <gl-toggle + v-if="!activeIssue.emailsDisabled" + :value="activeIssue.subscribed" + :is-loading="loading" + data-testid="notification-subscribe-toggle" + @change="handleToggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 2f64014a949..49cb560594c 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -18,7 +18,11 @@ export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; +/* eslint-disable-next-line @gitlab/require-i18n-strings */ +export const DEFAULT_LABELS = ['to do', 'doing']; + export default { BoardType, ListType, + DEFAULT_LABELS, }; diff --git a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql new file mode 100644 index 00000000000..1f383245ac2 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql @@ -0,0 +1,8 @@ +mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { + issueSetSubscription(input: $input) { + issue { + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 887abe79059..d3e40299d8d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; @@ -86,10 +86,17 @@ export default () => { boardId: $boardApp.dataset.boardId, groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, + currentUserId: gon.current_user_id || null, canUpdate: $boardApp.dataset.canUpdate, labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsManagePath: $boardApp.dataset.labelsManagePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, + timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours), + weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable), + boardWeight: $boardApp.dataset.boardWeight + ? parseInt($boardApp.dataset.boardWeight, 10) + : null, + scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels), }, store, apolloProvider, @@ -108,6 +115,7 @@ export default () => { }, computed: { ...mapState(['isShowingEpicsSwimlanes']), + ...mapGetters(['shouldUseGraphQL']), detailIssueVisible() { return Object.keys(this.detailIssue.issue).length; }, @@ -153,7 +161,7 @@ export default () => { boardsStore.disabled = this.disabled; - if (!gon.features.graphqlBoardLists) { + if (!this.shouldUseGraphQL) { this.initialBoardLoad(); } }, diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index fceb8c9d48e..f02c92e4230 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,17 +1,12 @@ /* global DocumentTouch */ -import $ from 'jquery'; import sortableConfig from 'ee_else_ce/sortable/sortable_config'; export function sortableStart() { - $('.has-tooltip') - .tooltip('hide') - .tooltip('disable'); document.body.classList.add('is-dragging'); } export function sortableEnd() { - $('.has-tooltip').tooltip('enable'); document.body.classList.remove('is-dragging'); } diff --git a/app/assets/javascripts/boards/queries/board_labels.query.graphql b/app/assets/javascripts/boards/queries/board_labels.query.graphql new file mode 100644 index 00000000000..42a94419a97 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_labels.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query BoardLabels( + $fullPath: ID! + $searchTerm: String + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + labels(searchTerm: $searchTerm) { + nodes { + ...Label + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + labels(searchTerm: $searchTerm) { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql new file mode 100644 index 00000000000..ef3fd36e980 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql @@ -0,0 +1,5 @@ +mutation DestroyBoardList($listId: ID!) { + destroyBoardList(input: { listId: $listId }) { + errors + } +} diff --git a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql new file mode 100644 index 00000000000..65be147be07 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql @@ -0,0 +1,10 @@ +#import "ee_else_ce/boards/queries/issue.fragment.graphql" + +mutation CreateIssue($input: CreateIssueInput!) { + createIssue(input: $input) { + issue { + ...IssueNode + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql new file mode 100644 index 00000000000..bbea248cf85 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql @@ -0,0 +1,8 @@ +mutation issueSetDueDate($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + dueDate + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/users_search.query.graphql b/app/assets/javascripts/boards/queries/users_search.query.graphql new file mode 100644 index 00000000000..ca016495d79 --- /dev/null +++ b/app/assets/javascripts/boards/queries/users_search.query.graphql @@ -0,0 +1,11 @@ +query usersSearch($search: String!) { + users(search: $search) { + nodes { + username + name + webUrl + avatarUrl + id + } + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1fed1228106..dd950a45076 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,26 +1,30 @@ -import Cookies from 'js-cookie'; 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 createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { BoardType, ListType, inactiveId } from '~/boards/constants'; +import { BoardType, ListType, inactiveId, DEFAULT_LABELS } from '~/boards/constants'; import * as types from './mutation_types'; import { formatBoardLists, formatListIssues, fullBoardId, formatListsPageInfo, + formatIssue, } from '../boards_util'; import boardStore from '~/boards/stores/boards_store'; +import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import listsIssuesQuery from '../queries/lists_issues.query.graphql'; +import boardLabelsQuery from '../queries/board_labels.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 destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql'; +import issueCreateMutation from '../queries/issue_create.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; +import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; +import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -83,7 +87,7 @@ export default { if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) { dispatch('createList', { backlog: true }); } - dispatch('showWelcomeList'); + dispatch('generateDefaultLists'); }) .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); }, @@ -121,7 +125,32 @@ export default { ); }, - showWelcomeList: ({ state, dispatch }) => { + showPromotionList: () => {}, + + fetchLabels: ({ state, commit }, searchTerm) => { + const { endpoints, boardType } = state; + const { fullPath } = endpoints; + + const variables = { + fullPath, + searchTerm, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, + }; + + return gqlClient + .query({ + query: boardLabelsQuery, + variables, + }) + .then(({ data }) => { + const labels = data[boardType]?.labels; + return labels.nodes; + }) + .catch(() => commit(types.RECEIVE_LABELS_FAILURE)); + }, + + generateDefaultLists: async ({ state, commit, dispatch }) => { if (state.disabled) { return; } @@ -132,22 +161,18 @@ export default { ) { 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: () => {}, + const fetchLabelsAndCreateList = label => { + return dispatch('fetchLabels', label) + .then(res => { + if (res.length > 0) { + dispatch('createList', { labelId: res[0].id }); + } + }) + .catch(() => commit(types.GENERATE_DEFAULT_LISTS_FAILURE)); + }; - generateDefaultLists: () => { - notImplemented(); + await Promise.all(DEFAULT_LABELS.map(fetchLabelsAndCreateList)); }, moveList: ( @@ -191,8 +216,26 @@ export default { }); }, - deleteList: () => { - notImplemented(); + removeList: ({ state, commit }, listId) => { + const listsBackup = { ...state.boardLists }; + + commit(types.REMOVE_LIST, listId); + + return gqlClient + .mutate({ + mutation: destroyBoardListMutation, + variables: { + listId, + }, + }) + .then(({ data: { destroyBoardList: { errors } } }) => { + if (errors.length > 0) { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + } + }) + .catch(() => { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + }); }, fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { @@ -271,20 +314,69 @@ export default { ); }, - createNewIssue: () => { - notImplemented(); + setAssignees: ({ commit, getters }, assigneeUsernames) => { + return gqlClient + .mutate({ + mutation: updateAssignees, + variables: { + iid: getters.activeIssue.iid, + projectPath: getters.activeIssue.referencePath.split('#')[0], + assigneeUsernames, + }, + }) + .then(({ data }) => { + commit('UPDATE_ISSUE_BY_ID', { + issueId: getters.activeIssue.id, + prop: 'assignees', + value: data.issueSetAssignees.issue.assignees.nodes, + }); + }); + }, + + createNewIssue: ({ commit, state }, issueInput) => { + const input = issueInput; + const { boardType, endpoints } = state; + if (boardType === BoardType.project) { + input.projectPath = endpoints.fullPath; + } + + return gqlClient + .mutate({ + mutation: issueCreateMutation, + variables: { input }, + }) + .then(({ data }) => { + if (data.createIssue.errors.length) { + commit(types.CREATE_ISSUE_FAILURE); + } else { + return data.createIssue?.issue; + } + return null; + }) + .catch(() => commit(types.CREATE_ISSUE_FAILURE)); }, 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 }); + addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => { + const issue = formatIssue({ ...issueInput, id: 'tmp' }); + commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 }); + + dispatch('createNewIssue', issueInput) + .then(res => { + commit(types.ADD_ISSUE_TO_LIST, { + list, + issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }), + }); + commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue }); + }) + .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id })); }, setActiveIssueLabels: async ({ commit, getters }, input) => { - const activeIssue = getters.getActiveIssue; + const { activeIssue } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabels, variables: { @@ -308,6 +400,53 @@ export default { }); }, + setActiveIssueDueDate: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetDueDate, + variables: { + input: { + iid: String(activeIssue.iid), + projectPath: input.projectPath, + dueDate: input.dueDate, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'dueDate', + value: data.updateIssue.issue.dueDate, + }); + }, + + setActiveIssueSubscribed: async ({ commit, getters }, input) => { + const { data } = await gqlClient.mutate({ + mutation: issueSetSubscriptionMutation, + variables: { + input: { + iid: String(getters.activeIssue.iid), + projectPath: input.projectPath, + subscribedState: input.subscribed, + }, + }, + }); + + if (data.issueSetSubscription?.errors?.length > 0) { + throw new Error(data.issueSetSubscription.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: getters.activeIssue.id, + prop: 'subscribed', + value: data.issueSetSubscription.issue.subscribed, + }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index d1a5db1bcc5..337b2897fe9 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,7 +1,6 @@ /* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ /* global ListIssue */ -import $ from 'jquery'; import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; @@ -119,8 +118,12 @@ const boardsStore = { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); }, + updateNewListDropdown(listId) { - $(`.js-board-list-${listId}`).removeClass('is-active'); + // eslint-disable-next-line no-unused-expressions + document + .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`) + ?.classList.remove('is-active'); }, shouldAddBlankState() { // Decide whether to add the blank state diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 89a3b14b262..cd28b4a0ff7 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -2,7 +2,7 @@ import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { - getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), + labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { @@ -15,15 +15,20 @@ export default { return state.issues[id] || {}; }, - getIssues: (state, getters) => listId => { + getIssuesByList: (state, getters) => listId => { const listIssueIds = state.issuesByListId[listId] || []; return listIssueIds.map(id => getters.getIssueById(id)); }, - getActiveIssue: state => { + activeIssue: state => { return state.issues[state.activeId] || {}; }, + projectPathForActiveIssue: (_, getters) => { + const referencePath = getters.activeIssue.referencePath || ''; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + getListByLabelId: state => labelId => { return find(state.boardLists, l => l.label?.id === labelId); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 09ab08062df..3a57cb9b5e1 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -2,6 +2,8 @@ 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_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; +export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_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'; @@ -10,12 +12,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_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 REMOVE_LIST = 'REMOVE_LIST'; +export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; 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 CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_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'; @@ -27,6 +29,7 @@ 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 REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 0c7dbc0d2ef..bb083158c8f 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -62,6 +62,14 @@ export default { state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, + [mutationTypes.RECEIVE_LABELS_FAILURE]: state => { + state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); + }, + + [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: state => { + state.error = s__('Boards|An error occurred while generating lists. Please reload the page.'); + }, + [mutationTypes.REQUEST_ADD_LIST]: () => { notImplemented(); }, @@ -85,16 +93,13 @@ export default { Vue.set(state, 'boardLists', backupList); }, - [mutationTypes.REQUEST_REMOVE_LIST]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST]: (state, listId) => { + Vue.delete(state.boardLists, listId); }, - [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) { + state.error = s__('Boards|An error occurred while removing the list. Please try again.'); + state.boardLists = listsBackup; }, [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { @@ -196,16 +201,28 @@ export default { notImplemented(); }, + [mutationTypes.CREATE_ISSUE_FAILURE]: state => { + state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + }, + [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); + addIssueToList({ + state, + listId: list.id, + issueId: issue.id, + atIndex: position, + }); Vue.set(state.issues, issue.id, issue); }, - [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { + [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + removeIssueFromList({ state, listId: list.id, issueId }); + }, + + [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { removeIssueFromList({ state, listId: list.id, issueId: issue.id }); + Vue.delete(state.issues, issue.id); }, [mutationTypes.SET_CURRENT_PAGE]: () => { diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index fa13d3a9e3c..347deb81846 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import { GlIcon } from '@gitlab/ui'; +import { hide } from '~/tooltips'; export default (ModalStore, boardsStore) => { const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); @@ -17,7 +18,9 @@ export default (ModalStore, boardsStore) => { }, methods: { toggleFocusMode() { - $(this.$refs.toggleFocusModeButton).tooltip('hide'); + const $el = $(this.$refs.toggleFocusModeButton); + hide($el); + issueBoardsContent.classList.toggle('is-focused'); this.isFullscreen = !this.isFullscreen; |