diff options
Diffstat (limited to 'app/assets/javascripts/boards')
16 files changed, 424 insertions, 82 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 6b7b0c2e28d..0142c95e773 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,5 +1,4 @@ import { sortBy } from 'lodash'; -import 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'; @@ -21,11 +20,11 @@ export function formatBoardLists(lists) { } export function formatIssue(issue) { - return new ListIssue({ + return { ...issue, labels: issue.labels?.nodes || [], assignees: issue.assignees?.nodes || [], - }); + }; } export function formatListIssues(listIssues) { @@ -44,12 +43,12 @@ export function formatListIssues(listIssues) { [list.id]: sortedIssues.map(i => { const id = getIdFromGraphQLId(i.id); - const listIssue = new ListIssue({ + const listIssue = { ...i, id, labels: i.labels?.nodes || [], assignees: i.assignees?.nodes || [], - }); + }; issues[id] = listIssue; @@ -83,21 +82,30 @@ export function fullLabelId(label) { } export function moveIssueListHelper(issue, fromList, toList) { - if (toList.type === ListType.label) { - issue.addLabel(toList.label); + const updatedIssue = issue; + if ( + toList.type === ListType.label && + !updatedIssue.labels.find(label => label.id === toList.label.id) + ) { + updatedIssue.labels.push(toList.label); } - if (fromList && fromList.type === ListType.label) { - issue.removeLabel(fromList.label); + if (fromList?.label && fromList.type === ListType.label) { + updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id); } - if (toList.type === ListType.assignee) { - issue.addAssignee(toList.assignee); + if ( + toList.type === ListType.assignee && + !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id) + ) { + updatedIssue.assignees.push(toList.assignee); } - if (fromList && fromList.type === ListType.assignee) { - issue.removeAssignee(fromList.assignee); + if (fromList?.assignee && fromList.type === ListType.assignee) { + updatedIssue.assignees = updatedIssue.assignees.filter( + assignee => assignee.id !== fromList.assignee.id, + ); } - return issue; + return updatedIssue; } export default { diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index c81f171af2b..231cde0d24f 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -1,11 +1,13 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { cloneDeep } from 'lodash'; import { GlDropdownItem, GlDropdownDivider, GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; @@ -32,12 +34,13 @@ export default { GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, }, data() { return { search: '', participants: [], - selected: this.$store.getters.activeIssue.assignees, + selected: [], }; }, apollo: { @@ -89,9 +92,20 @@ export default { isSearchEmpty() { return this.search === ''; }, + currentUser() { + return gon?.current_username; + }, + }, + created() { + this.selected = cloneDeep(this.activeIssue.assignees); }, methods: { ...mapActions(['setAssignees']), + async assignSelf() { + const [currentUserObject] = await this.setAssignees(this.currentUser); + + this.selectAssignee(currentUserObject); + }, clearSelected() { this.selected = []; }, @@ -119,7 +133,7 @@ export default { <template> <board-editable-item :title="assigneeText" @close="saveAssignees"> <template #collapsed> - <issuable-assignees :users="activeIssue.assignees" /> + <issuable-assignees :users="selected" @assign-self="assignSelf" /> </template> <template #default> @@ -132,45 +146,48 @@ export default { <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> + <gl-loading-icon v-if="$apollo.queries.participants.loading" size="lg" /> + <template v-else> + <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> </template> </multi-select-dropdown> </template> diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue index 8a59355eb83..7162423846b 100644 --- a/app/assets/javascripts/boards/components/board_column_new.vue +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -85,6 +85,7 @@ export default { :disabled="disabled" :issues="listIssues" :list="list" + :can-admin-list="canAdminList" /> <!-- Will be only available in EE --> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 53989e2d9de..1f87b563e73 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,7 +6,6 @@ import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, @@ -25,7 +24,6 @@ export default { boardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index d85ba2038a7..722bd20f227 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -190,7 +190,8 @@ export default { :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue index 99347a4cd4d..447fef4b49c 100644 --- a/app/assets/javascripts/boards/components/board_list_header_new.vue +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -198,7 +198,8 @@ export default { :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- EE start --> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 396aedcc557..291abc9849b 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,12 +1,14 @@ <script> +import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import defaultSortableConfig from '~/sortable/sortable_config'; +import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'BoardList', @@ -15,7 +17,6 @@ export default { BoardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, @@ -29,6 +30,11 @@ export default { type: Array, required: true, }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -55,12 +61,32 @@ export default { loading() { return this.listsFlags[this.list.id]?.isLoading; }, + listRef() { + // When list is draggable, the reference to the list needs to be accessed differently + return this.canAdminList ? this.$refs.list.$el : this.$refs.list; + }, + treeRootWrapper() { + return this.canAdminList ? Draggable : 'ul'; + }, + treeRootOptions() { + const options = { + ...defaultSortableConfig, + fallbackOnBody: false, + group: 'boards-list', + tag: 'ul', + 'ghost-class': 'board-card-drag-active', + 'data-list-id': this.list.id, + value: this.issues, + }; + + return this.canAdminList ? options : {}; + }, }, watch: { filters: { handler() { this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, deep: true, }, @@ -76,26 +102,26 @@ export default { }, mounted() { // Scroll event on list to load more - this.$refs.list.addEventListener('scroll', this.onScroll); + this.listRef.addEventListener('scroll', this.onScroll); }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.$refs.list.removeEventListener('scroll', this.onScroll); + this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchIssuesForList', 'moveIssue']), listHeight() { - return this.$refs.list.getBoundingClientRect().height; + return this.listRef.getBoundingClientRect().height; }, scrollHeight() { - return this.$refs.list.scrollHeight; + return this.listRef.scrollHeight; }, scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); + return this.listRef.scrollTop + this.listHeight(); }, scrollToTop() { - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, loadNextPage() { const loadingDone = () => { @@ -120,6 +146,52 @@ export default { } }); }, + handleDragOnStart() { + sortableStart(); + }, + handleDragOnEnd(params) { + sortableEnd(); + const { newIndex, oldIndex, from, to, item } = params; + const { issueId, issueIid, issuePath } = item.dataset; + const { children } = to; + let moveBeforeId; + let moveAfterId; + + const getIssueId = el => Number(el.dataset.issueId); + + // If issue is being moved within the same list + if (from === to) { + if (newIndex > oldIndex && children.length > 1) { + // If issue is being moved down we look for the issue that ends up before + moveBeforeId = getIssueId(children[newIndex]); + } else if (newIndex < oldIndex && children.length > 1) { + // If issue is being moved up we look for the issue that ends up after + moveAfterId = getIssueId(children[newIndex]); + } else { + // If issue remains in the same list at the same position we do nothing + return; + } + } else { + // We look for the issue that ends up before the moved issue if it exists + if (children[newIndex - 1]) { + moveBeforeId = getIssueId(children[newIndex - 1]); + } + // We look for the issue that ends up after the moved issue if it exists + if (children[newIndex]) { + moveAfterId = getIssueId(children[newIndex]); + } + } + + this.moveIssue({ + issueId, + issueIid, + issuePath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); + }, }, }; </script> @@ -139,13 +211,18 @@ export default { <gl-loading-icon /> </div> <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> - <ul + <component + :is="treeRootWrapper" v-show="!loading" ref="list" + v-bind="treeRootOptions" :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" + data-testid="tree-root-wrapper" + @start="handleDragOnStart" + @end="handleDragOnEnd" > <board-card v-for="(issue, index) in issues" @@ -161,6 +238,6 @@ export default { <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> - </ul> + </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 45ce1e51489..67e8a32dbe2 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -158,9 +158,13 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ - issue.title - }}</a> + <a + :href="issue.path || issue.webUrl || ''" + :title="issue.title" + class="js-no-trigger" + @mousemove.stop + >{{ issue.title }}</a + > </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> @@ -196,7 +200,11 @@ export default { #{{ issue.iid }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> - <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate" + :closed="issue.closed || Boolean(issue.closedAt)" + /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight v-if="validIssueWeight" 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 5fb7a9b210c..ce267be6d45 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -50,6 +50,13 @@ export default { } window.removeEventListener('click', this.collapseWhenOffClick); }, + toggle({ emitEvent = true } = {}) { + if (this.edit) { + this.collapse({ emitEvent }); + } else { + this.expand(); + } + }, }, }; </script> @@ -64,18 +71,18 @@ export default { <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900!" + class="gl-text-gray-900! js-sidebar-dropdown-toggle" data-testid="edit-button" - @click="expand()" + @click="toggle" > {{ __('Edit') }} </gl-button> </div> - <div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content"> + <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content"> - <slot></slot> + <slot :edit="edit"></slot> </div> </div> </template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 6935ead2706..904ceaed1b3 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -79,7 +79,7 @@ export default { <span class="gl-mx-2">-</span> <gl-button variant="link" - class="gl-text-gray-400!" + class="gl-text-gray-500!" data-testid="reset-button" :disabled="loading" @click="setDueDate(null)" 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 9d537a4ef2c..6a407bd6ba6 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 @@ -92,7 +92,7 @@ export default { @close="removeLabel(label.id)" /> </template> - <template> + <template #default="{ edit }"> <labels-select ref="labelsSelect" :allow-label-edit="false" @@ -105,6 +105,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-list-title="__('Select label')" :dropdown-button-text="__('Choose labels')" + :is-editing="edit" variant="embedded" class="gl-display-block labels gl-w-full" @updateSelectedLabels="setLabels" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue new file mode 100644 index 00000000000..f7b7fd3f61f --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -0,0 +1,161 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import groupMilestones from '../../queries/group_milestones.query.graphql'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlDropdown, + GlLoadingIcon, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + }, + data() { + return { + milestones: [], + searchTitle: '', + loading: false, + edit: false, + }; + }, + apollo: { + milestones: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: groupMilestones, + debounce: 250, + skip() { + return !this.edit; + }, + variables() { + return { + fullPath: this.groupFullPath, + searchTitle: this.searchTitle, + state: 'active', + includeDescendants: true, + }; + }, + update(data) { + const edges = data?.group?.milestones?.edges ?? []; + return edges.map(item => item.node); + }, + error() { + createFlash({ message: this.$options.i18n.fetchMilestonesError }); + }, + }, + }, + computed: { + ...mapGetters({ issue: 'activeIssue' }), + hasMilestone() { + return this.issue.milestone !== null; + }, + groupFullPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('/')); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + dropdownText() { + return this.issue.milestone?.title ?? this.$options.i18n.noMilestone; + }, + }, + mounted() { + this.$root.$on('bv::dropdown::hide', () => { + this.$refs.sidebarItem.collapse(); + }); + }, + methods: { + ...mapActions(['setActiveIssueMilestone']), + handleOpen() { + this.edit = true; + this.$refs.dropdown.show(); + }, + async setMilestone(milestoneId) { + this.loading = true; + this.searchTitle = ''; + this.$refs.sidebarItem.collapse(); + + try { + const input = { milestoneId, projectPath: this.projectPath }; + await this.setActiveIssueMilestone(input); + } catch (e) { + createFlash({ message: this.$options.i18n.updateMilestoneError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + milestone: __('Milestone'), + noMilestone: __('No milestone'), + assignMilestone: __('Assign milestone'), + noMilestonesFound: s__('Milestones|No milestones found'), + fetchMilestonesError: __('There was a problem fetching milestones.'), + updateMilestoneError: __('An error occurred while updating the milestone.'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + :title="$options.i18n.milestone" + :loading="loading" + @open="handleOpen()" + @close="edit = false" + > + <template v-if="hasMilestone" #collapsed> + <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong> + </template> + <template> + <gl-dropdown + ref="dropdown" + :text="dropdownText" + :header-text="$options.i18n.assignMilestone" + block + > + <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> + <gl-dropdown-item + data-testid="no-milestone-item" + :is-check-item="true" + :is-checked="!issue.milestone" + @click="setMilestone(null)" + > + {{ $options.i18n.noMilestone }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> + <template v-else-if="milestones.length > 0"> + <gl-dropdown-item + v-for="milestone in milestones" + :key="milestone.id" + :is-check-item="true" + :is-checked="issue.milestone && milestone.id === issue.milestone.id" + data-testid="milestone-item" + @click="setMilestone(milestone.id)" + > + {{ milestone.title }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else data-testid="no-milestones-found"> + {{ $options.i18n.noMilestonesFound }} + </gl-dropdown-text> + </gl-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/queries/group_milestones.query.graphql b/app/assets/javascripts/boards/queries/group_milestones.query.graphql new file mode 100644 index 00000000000..f2ab12ef4a7 --- /dev/null +++ b/app/assets/javascripts/boards/queries/group_milestones.query.graphql @@ -0,0 +1,17 @@ +query groupMilestones( + $fullPath: ID! + $state: MilestoneStateEnum + $includeDescendants: Boolean + $searchTitle: String +) { + group(fullPath: $fullPath) { + milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) { + edges { + node { + id + title + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/queries/issue.fragment.graphql index 4b429f875a6..1395bef39ed 100644 --- a/app/assets/javascripts/boards/queries/issue.fragment.graphql +++ b/app/assets/javascripts/boards/queries/issue.fragment.graphql @@ -11,6 +11,10 @@ fragment IssueNode on Issue { webUrl subscribed relativePosition + milestone { + id + title + } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql new file mode 100644 index 00000000000..5dc78a03a06 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql @@ -0,0 +1,12 @@ +mutation issueSetMilestone($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + milestone { + id + title + description + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index dd950a45076..00c64bff74e 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -25,6 +25,7 @@ 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'; +import issueSetMilestone from '../queries/issue_set_milestone.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -325,14 +326,42 @@ export default { }, }) .then(({ data }) => { + const { nodes } = data.issueSetAssignees?.issue?.assignees || []; + commit('UPDATE_ISSUE_BY_ID', { issueId: getters.activeIssue.id, prop: 'assignees', - value: data.issueSetAssignees.issue.assignees.nodes, + value: nodes, }); + + return nodes; }); }, + setActiveIssueMilestone: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetMilestone, + variables: { + input: { + iid: String(activeIssue.iid), + milestoneId: getIdFromGraphQLId(input.milestoneId), + 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: 'milestone', + value: data.updateIssue.issue.milestone, + }); + }, + createNewIssue: ({ commit, state }, issueInput) => { const input = issueInput; const { boardType, endpoints } = state; |