diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 12:08:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 12:08:42 +0300 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/boards | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards')
25 files changed, 515 insertions, 175 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 46f97e09385..3219d74f85f 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -204,6 +204,9 @@ export const FiltersInfo = { releaseTag: { negatedSupport: true, }, + types: { + negatedSupport: true, + }, search: { negatedSupport: false, }, diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 05b64ddc773..5658a34e9a6 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -65,7 +65,7 @@ export default { }, computed: { ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']), - ...mapGetters(['isEpicBoard']), + ...mapGetters(['isEpicBoard', 'isProjectBoard']), cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness @@ -144,6 +144,9 @@ export default { totalProgress() { return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100); }, + showReferencePath() { + return !this.isProjectBoard && this.itemReferencePath; + }, }, methods: { ...mapActions(['performSearch', 'setError']), @@ -247,7 +250,7 @@ export default { :class="{ 'gl-font-base': isEpicBoard }" > <tooltip-on-truncate - v-if="itemReferencePath" + v-if="showReferencePath" :title="itemReferencePath" placement="bottom" class="board-item-path gl-text-truncate gl-font-weight-bold" diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 69abf886ad7..bcf5b12b209 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -79,7 +79,7 @@ export default { 'is-collapsed': list.collapsed, 'board-type-assignee': list.listType === 'assignee', }" - :data-id="list.id" + :data-list-id="list.id" class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable" data-qa-selector="board_list" > diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 53b071aaed1..4df6ff75249 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -6,10 +6,12 @@ import { mapState, mapGetters, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import defaultSortableConfig from '~/sortable/sortable_config'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { DraggableItemTypes } from '../constants'; import BoardColumn from './board_column.vue'; import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { + draggableItemTypes: DraggableItemTypes, components: { BoardAddNewColumn, BoardColumn, @@ -76,19 +78,6 @@ export default { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - handleDragOnEnd(params) { - const { item, newIndex, oldIndex, to } = params; - - const listId = item.dataset.id; - const replacedListId = to.children[newIndex].dataset.id; - - this.moveList({ - listId, - replacedListId, - newIndex, - adjustmentValue: newIndex < oldIndex ? 1 : -1, - }); - }, }, }; </script> @@ -104,7 +93,7 @@ export default { ref="list" v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" - @end="handleDragOnEnd" + @end="moveList" > <component :is="boardColumnComponent" @@ -112,6 +101,7 @@ export default { :key="index" ref="board" :list="list" + :data-draggable-item-type="$options.draggableItemTypes.list" :disabled="disabled" /> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index e014b82d362..7a936e75676 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -87,6 +87,7 @@ export default { v-bind="$attrs" :open="isSidebarOpen" class="boards-sidebar gl-absolute" + variant="sidebar" @close="handleClose" > <template #title> @@ -159,7 +160,7 @@ export default { :issuable-type="issuableType" data-testid="sidebar-due-date" /> - <board-sidebar-labels-select class="labels" /> + <board-sidebar-labels-select class="block labels" /> <sidebar-weight-widget v-if="weightFeatureAvailable" :iid="activeBoardItem.iid" diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index cfd6b21fa66..7f242dea644 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -27,7 +27,15 @@ export default { }, computed: { urlParams() { - const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; + const { + authorUsername, + labelName, + assigneeUsername, + search, + milestoneTitle, + types, + weight, + } = this.filterParams; let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -36,6 +44,9 @@ export default { 'not[label_name][]': this.filterParams.not.labelName, 'not[author_username]': this.filterParams.not.authorUsername, 'not[assignee_username]': this.filterParams.not.assigneeUsername, + 'not[types]': this.filterParams.not.types, + 'not[milestone_title]': this.filterParams.not.milestoneTitle, + 'not[weight]': this.filterParams.not.weight, }, undefined, ); @@ -46,7 +57,10 @@ export default { author_username: authorUsername, 'label_name[]': labelName, assignee_username: assigneeUsername, + milestone_title: milestoneTitle, search, + types, + weight, }; }, }, @@ -64,7 +78,15 @@ export default { this.performSearch(); }, getFilteredSearchValue() { - const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; + const { + authorUsername, + labelName, + assigneeUsername, + search, + milestoneTitle, + types, + weight, + } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { @@ -81,6 +103,13 @@ export default { }); } + if (types) { + filteredSearchValue.push({ + type: 'types', + value: { data: types, operator: '=' }, + }); + } + if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ @@ -90,6 +119,20 @@ export default { ); } + if (milestoneTitle) { + filteredSearchValue.push({ + type: 'milestone_title', + value: { data: milestoneTitle, operator: '=' }, + }); + } + + if (weight) { + filteredSearchValue.push({ + type: 'weight', + value: { data: weight, operator: '=' }, + }); + } + if (this.filterParams['not[authorUsername]']) { filteredSearchValue.push({ type: 'author_username', @@ -97,6 +140,20 @@ export default { }); } + if (this.filterParams['not[milestoneTitle]']) { + filteredSearchValue.push({ + type: 'milestone_title', + value: { data: this.filterParams['not[milestoneTitle]'], operator: '!=' }, + }); + } + + if (this.filterParams['not[weight]']) { + filteredSearchValue.push({ + type: 'weight', + value: { data: this.filterParams['not[weight]'], operator: '!=' }, + }); + } + if (this.filterParams['not[assigneeUsername]']) { filteredSearchValue.push({ type: 'assignee_username', @@ -113,6 +170,13 @@ export default { ); } + if (this.filterParams['not[types]']) { + filteredSearchValue.push({ + type: 'types', + value: { data: this.filterParams['not[types]'], operator: '!=' }, + }); + } + if (search) { filteredSearchValue.push(search); } @@ -140,9 +204,18 @@ export default { case 'assignee_username': filterParams.assigneeUsername = filter.value.data; break; + case 'types': + filterParams.types = filter.value.data; + break; case 'label_name': labels.push(filter.value.data); break; + case 'milestone_title': + filterParams.milestoneTitle = filter.value.data; + break; + case 'weight': + filterParams.weight = filter.value.data; + break; case 'filtered-search-term': if (filter.value.data) plainText.push(filter.value.data); break; diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 386ed6bd0a1..a89f71504a9 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -2,7 +2,7 @@ import { GlModal, GlAlert } from '@gitlab/ui'; import { mapGetters, mapActions, mapState } from 'vuex'; import ListLabel from '~/boards/models/label'; -import { TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants'; +import { TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -18,10 +18,9 @@ const boardDefaults = { id: false, name: '', labels: [], - milestone_id: undefined, + milestone: {}, iteration_id: undefined, assignee: {}, - assignee_id: undefined, weight: null, hide_backlog_list: false, hide_closed_list: false, @@ -190,13 +189,10 @@ export default { issueBoardScopeMutationVariables() { return { weight: this.board.weight, - assigneeId: this.board.assignee?.id - ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) + assigneeId: this.board.assignee?.id || null, + milestoneId: this.board.milestone?.id + ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) : null, - milestoneId: - this.board.milestone?.id || this.board.milestone?.id === 0 - ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) - : null, iterationId: this.board.iteration_id ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id) : null, @@ -306,6 +302,19 @@ export default { } }); }, + setAssignee(assigneeId) { + this.$set(this.board, 'assignee', { + id: assigneeId, + }); + }, + setMilestone(milestoneId) { + this.$set(this.board, 'milestone', { + id: milestoneId, + }); + }, + setWeight(weight) { + this.$set(this.board, 'weight', weight); + }, }, }; </script> @@ -373,6 +382,9 @@ export default { :weights="weights" @set-iteration="setIteration" @set-board-labels="setBoardLabels" + @set-assignee="setAssignee" + @set-milestone="setMilestone" + @set-weight="setWeight" /> </form> </gl-modal> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 8dca6be853f..849492effab 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,12 +6,13 @@ import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_opt import { sprintf, __ } from '~/locale'; import defaultSortableConfig from '~/sortable/sortable_config'; import Tracking from '~/tracking'; -import { toggleFormEventPrefix } from '../constants'; +import { toggleFormEventPrefix, DraggableItemTypes } from '../constants'; import eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; export default { + draggableItemTypes: DraggableItemTypes, name: 'BoardList', i18n: { loading: __('Loading'), @@ -27,11 +28,6 @@ export default { GlIntersectionObserver, }, mixins: [Tracking.mixin()], - inject: { - canAdminList: { - default: false, - }, - }, props: { disabled: { type: Boolean, @@ -89,8 +85,8 @@ export default { return !this.isEpicBoard && this.list.listType !== 'closed' && this.showIssueForm; }, 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; + // When list is draggable, the reference to the list needs to be accessed differently + return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list; }, showingAllItems() { return this.boardItems.length === this.listItemsCount; @@ -100,8 +96,11 @@ export default { ? this.$options.i18n.showingAllEpics : this.$options.i18n.showingAllIssues; }, + canMoveIssue() { + return !this.disabled; + }, treeRootWrapper() { - return this.canAdminList && !this.listsFlags[this.list.id]?.addItemToListInProgress + return this.canMoveIssue && !this.listsFlags[this.list.id]?.addItemToListInProgress ? Draggable : 'ul'; }, @@ -116,7 +115,7 @@ export default { value: this.boardItems, }; - return this.canAdminList ? options : {}; + return this.canMoveIssue ? options : {}; }, }, watch: { @@ -172,15 +171,33 @@ export default { this.loadNextPage(); } }, - handleDragOnStart() { + handleDragOnStart({ + item: { + dataset: { draggableItemType }, + }, + }) { + if (draggableItemType !== DraggableItemTypes.card) { + return; + } + sortableStart(); this.track('drag_card', { label: 'board' }); }, - handleDragOnEnd(params) { + handleDragOnEnd({ + newIndex: originalNewIndex, + oldIndex, + from, + to, + item: { + dataset: { draggableItemType, itemId, itemIid, itemPath }, + }, + }) { + if (draggableItemType !== DraggableItemTypes.card) { + return; + } + sortableEnd(); - const { oldIndex, from, to, item } = params; - let { newIndex } = params; - const { itemId, itemIid, itemPath } = item.dataset; + let newIndex = originalNewIndex; let { children } = to; let moveBeforeId; let moveAfterId; @@ -267,6 +284,7 @@ export default { :index="index" :list="list" :item="item" + :data-draggable-item-type="$options.draggableItemTypes.card" :disabled="disabled" /> <gl-intersection-observer @appear="onReachingListBottom"> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index caeecb25227..84c9191975e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,21 +1,19 @@ <script> -import { GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; -import { __ } from '~/locale'; + import { toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; + +import BoardNewItem from './board_new_item.vue'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', - i18n: { - cancel: __('Cancel'), - }, components: { + BoardNewItem, ProjectSelect, - GlButton, }, mixins: [BoardNewIssueMixin], inject: ['groupId'], @@ -25,106 +23,55 @@ export default { required: true, }, }, - data() { - return { - title: '', - }; - }, computed: { - ...mapState(['selectedProject']), - ...mapGetters(['isGroupBoard', 'isEpicBoard']), - /** - * We've extended this component in EE where - * submitButtonTitle returns a different string - * hence this is kept as a computed prop. - */ - submitButtonTitle() { - return __('Create issue'); + ...mapState(['selectedProject', 'fullPath']), + ...mapGetters(['isGroupBoard']), + formEventPrefix() { + return toggleFormEventPrefix.issue; }, - disabled() { - if (this.isGroupBoard) { - return this.title === '' || !this.selectedProject.name; - } - return this.title === ''; + disableSubmit() { + return this.isGroupBoard ? !this.selectedProject.name : false; }, - inputFieldId() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `${this.list.id}-title`; + projectPath() { + return this.isGroupBoard ? this.selectedProject.fullPath : this.fullPath; }, }, - mounted() { - this.$refs.input.focus(); - eventHub.$on('setSelectedProject', this.setSelectedProject); - }, methods: { ...mapActions(['addListNewIssue']), - submit() { - const { title } = this; + submit({ title }) { const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - eventHub.$emit(`scroll-board-list-${this.list.id}`); - return this.addListNewIssue({ + list: this.list, issueInput: { title, labelIds: labels?.map((l) => l.id), assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, - projectPath: this.selectedProject.fullPath, - ...this.extraIssueInput(), + projectPath: this.projectPath, }, - list: this.list, }).then(() => { - this.reset(); + this.cancel(); }); }, - reset() { - this.title = ''; - eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + cancel() { + eventHub.$emit(`${this.formEventPrefix}${this.list.id}`); }, }, }; </script> <template> - <div class="board-new-issue-form"> - <div class="board-card position-relative p-3 rounded"> - <form ref="submitForm" @submit.prevent="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="isGroupBoard && !isEpicBoard" :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="confirm" - category="primary" - type="submit" - > - {{ submitButtonTitle }} - </gl-button> - <gl-button - ref="cancelButton" - class="float-right" - type="button" - variant="default" - @click="reset" - > - {{ $options.i18n.cancel }} - </gl-button> - </div> - </form> - </div> - </div> + <board-new-item + :list="list" + :form-event-prefix="formEventPrefix" + :submit-button-title="__('Create issue')" + :disable-submit="disableSubmit" + @form-submit="submit" + @form-cancel="cancel" + > + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + </board-new-item> </template> diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index 1218941065f..a25b436b8de 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -11,7 +11,7 @@ import ProjectSelect from './project_select_deprecated.vue'; // This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards export default { - name: 'BoardNewIssue', + name: 'BoardNewIssueDeprecated', components: { ProjectSelect, GlButton, diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue new file mode 100644 index 00000000000..44574de17d7 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_item.vue @@ -0,0 +1,95 @@ +<script> +import { GlForm, GlFormInput, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +import eventHub from '../eventhub'; + +export default { + i18n: { + cancel: __('Cancel'), + }, + components: { + GlForm, + GlFormInput, + GlButton, + }, + props: { + list: { + type: Object, + required: true, + }, + formEventPrefix: { + type: String, + required: true, + }, + disableSubmit: { + type: Boolean, + required: false, + default: false, + }, + submitButtonTitle: { + type: String, + required: false, + default: __('Create issue'), + }, + }, + data() { + return { + title: '', + }; + }, + computed: { + inputFieldId() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.list.id}-title`; + }, + }, + methods: { + handleFormCancel() { + this.title = ''; + this.$emit('form-cancel'); + }, + handleFormSubmit() { + const { title, list } = this; + + eventHub.$emit(`scroll-board-list-${this.list.id}`); + this.$emit('form-submit', { + title, + list, + }); + }, + }, +}; +</script> + +<template> + <div class="board-new-issue-form"> + <div class="board-card position-relative gl-p-5 rounded"> + <gl-form @submit.prevent="handleFormSubmit" @reset="handleFormCancel"> + <label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label> + <gl-form-input + :id="inputFieldId" + v-model.trim="title" + :autofocus="true" + autocomplete="off" + type="text" + name="issue_title" + /> + <slot></slot> + <div class="gl-clearfix gl-mt-4"> + <gl-button + :disabled="!title || disableSubmit" + class="gl-float-left js-no-auto-disable" + variant="confirm" + type="submit" + > + {{ submitButtonTitle }} + </gl-button> + <gl-button class="gl-float-right js-no-auto-disable" type="reset"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index d8dac17d326..5206db05410 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -1,4 +1,6 @@ <script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { mapActions } from 'vuex'; import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; import issueBoardFilters from '~/boards/issue_board_filters'; import { TYPE_USER } from '~/graphql_shared/constants'; @@ -6,13 +8,24 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; export default { + types: { + ISSUE: 'ISSUE', + INCIDENT: 'INCIDENT', + }, i18n: { search: __('Search'), label: __('Label'), author: __('Author'), assignee: __('Assignee'), + type: __('Type'), + incident: __('Incident'), + issue: __('Issue'), + milestone: __('Milestone'), + weight: __('Weight'), is: __('is'), isNot: __('is not'), }, @@ -29,7 +42,19 @@ export default { }, computed: { tokens() { - const { label, is, isNot, author, assignee } = this.$options.i18n; + const { + label, + is, + isNot, + author, + assignee, + issue, + incident, + type, + milestone, + weight, + } = this.$options.i18n; + const { types } = this.$options; const { fetchAuthors, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, @@ -77,10 +102,40 @@ export default { fetchAuthors, preloadedAuthors: this.preloadedAuthors(), }, + { + icon: 'issues', + title: type, + type: 'types', + operators: [{ value: '=', description: is }], + token: GlFilteredSearchToken, + unique: true, + options: [ + { icon: 'issue-type-issue', value: types.ISSUE, title: issue }, + { icon: 'issue-type-incident', value: types.INCIDENT, title: incident }, + ], + }, + { + type: 'milestone_title', + title: milestone, + icon: 'clock', + symbol: '%', + token: MilestoneToken, + unique: true, + defaultMilestones: [], // todo: https://gitlab.com/gitlab-org/gitlab/-/issues/337044#note_640010094 + fetchMilestones: this.fetchMilestones, + }, + { + type: 'weight', + title: weight, + icon: 'weight', + token: WeightToken, + unique: true, + }, ]; }, }, methods: { + ...mapActions(['fetchMilestones']), preloadedAuthors() { return gon?.current_user_id ? [ 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 84802650dad..e7696b8d31b 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -87,7 +87,7 @@ export default { <div> <header v-show="showHeader" - class="gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-mb-3" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-mb-2" > <span class="gl-vertical-align-middle"> <slot name="title"> @@ -97,7 +97,8 @@ export default { </span> <gl-button v-if="canUpdate" - variant="link" + category="tertiary" + size="small" class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link" data-testid="edit-button" @click="toggle" 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 29febd0fa51..e74463825c5 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 @@ -25,6 +25,8 @@ export default { data() { return { loading: false, + oldIid: null, + isEditing: false, }; }, computed: { @@ -72,6 +74,15 @@ export default { return this.labelsFetchPath || projectLabelsFetchPath; }, }, + watch: { + activeBoardItem(_, oldVal) { + if (this.isEditing) { + this.oldIid = oldVal.iid; + } else { + this.oldIid = null; + } + }, + }, methods: { ...mapActions(['setActiveBoardItemLabels', 'setError']), async setLabels(payload) { @@ -84,8 +95,14 @@ export default { .filter((label) => !payload.find((selected) => selected.id === label.id)) .map((label) => label.id); - const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; + const input = { + addLabelIds, + removeLabelIds, + projectPath: this.projectPathForActiveIssue, + iid: this.oldIid, + }; await this.setActiveBoardItemLabels(input); + this.oldIid = null; } catch (e) { this.setError({ error: e, message: __('An error occurred while updating labels.') }); } finally { @@ -115,6 +132,8 @@ export default { :title="__('Labels')" :loading="loading" data-testid="sidebar-labels" + @open="isEditing = true" + @close="isEditing = false" > <template #collapsed> <gl-label diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 21ef70582a4..16fb4596726 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -109,9 +109,16 @@ export const FilterFields = { 'myReactionEmoji', 'releaseTag', 'search', + 'types', + 'weight', ], }; +export const DraggableItemTypes = { + card: 'card', + list: 'list', +}; + export default { BoardType, ListType, diff --git a/app/assets/javascripts/boards/graphql/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql index eb922f162f8..734867c77e9 100644 --- a/app/assets/javascripts/boards/graphql/board_lists.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql @@ -9,6 +9,7 @@ query ListIssues( ) { group(fullPath: $fullPath) @include(if: $isGroup) { board(id: $boardId) { + hideBacklogList lists(issueFilters: $filters) { nodes { ...BoardListFragment @@ -18,6 +19,7 @@ query ListIssues( } project(fullPath: $fullPath) @include(if: $isProject) { board(id: $boardId) { + hideBacklogList lists(issueFilters: $filters) { nodes { ...BoardListFragment diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql index 3b8c5389725..d3251c2aa12 100644 --- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql @@ -3,7 +3,7 @@ query GroupBoardMembers($fullPath: ID!, $search: String) { workspace: group(fullPath: $fullPath) { __typename - assignees: groupMembers(search: $search) { + assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) { __typename nodes { id diff --git a/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql new file mode 100644 index 00000000000..73aa9137dec --- /dev/null +++ b/app/assets/javascripts/boards/graphql/group_board_milestones.query.graphql @@ -0,0 +1,10 @@ +query GroupBoardMilestones($fullPath: ID!, $searchTerm: String) { + group(fullPath: $fullPath) { + milestones(includeAncestors: true, searchTitle: $searchTerm) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql new file mode 100644 index 00000000000..8dd4d256caa --- /dev/null +++ b/app/assets/javascripts/boards/graphql/project_board_milestones.query.graphql @@ -0,0 +1,10 @@ +query ProjectBoardMilestones($fullPath: ID!, $searchTerm: String) { + project(fullPath: $fullPath) { + milestones(searchTitle: $searchTerm, includeAncestors: true) { + nodes { + id + title + } + } + } +} diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 7f655091cd0..7d6179a8547 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -11,7 +11,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); export default (params = {}) => { diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 0f1b72146c9..970d00841bd 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/browser'; +import { sortBy } from 'lodash'; import { BoardType, ListType, @@ -13,14 +14,14 @@ import { issuableTypes, FilterFields, ListTypeTitles, + DraggableItemTypes, } from 'ee_else_ce/boards/constants'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { urlParamsToObject } from '~/lib/utils/url_utility'; +import { queryToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { formatBoardLists, @@ -35,10 +36,13 @@ import { filterVariables, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; +import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; +import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql'; + import * as types from './mutation_types'; export const gqlClient = createGqClient( @@ -76,8 +80,7 @@ export default { performSearch({ dispatch }) { dispatch( 'setFilters', - // eslint-disable-next-line import/no-deprecated - convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)), + convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })), ); if (gon.features.graphqlBoardLists) { @@ -215,34 +218,99 @@ export default { }); }, + fetchMilestones({ state, commit }, searchTerm) { + commit(types.RECEIVE_MILESTONES_REQUEST); + + const { fullPath, boardType } = state; + + const variables = { + fullPath, + searchTerm, + }; + + let query; + if (boardType === BoardType.project) { + query = projectBoardMilestonesQuery; + } + if (boardType === BoardType.group) { + query = groupBoardMilestonesQuery; + } + + if (!query) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Unknown board type'); + } + + return gqlClient + .query({ + query, + variables, + }) + .then(({ data }) => { + const errors = data[boardType]?.errors; + const milestones = data[boardType]?.milestones.nodes; + + if (errors?.[0]) { + throw new Error(errors[0]); + } + + commit(types.RECEIVE_MILESTONES_SUCCESS, milestones); + + return milestones; + }) + .catch((e) => { + commit(types.RECEIVE_MILESTONES_FAILURE); + throw e; + }); + }, + moveList: ( - { state, commit, dispatch }, - { listId, replacedListId, newIndex, adjustmentValue }, + { state: { boardLists }, commit, dispatch }, + { + item: { + dataset: { listId: movedListId, draggableItemType }, + }, + newIndex, + to: { children }, + }, ) => { - if (listId === replacedListId) { + if (draggableItemType !== DraggableItemTypes.list) { return; } - const { boardLists } = state; - const backupList = { ...boardLists }; - const movedList = boardLists[listId]; + const displacedListId = children[newIndex].dataset.listId; + if (movedListId === displacedListId) { + return; + } - const newPosition = newIndex - 1; - const listAtNewIndex = boardLists[replacedListId]; + const listIds = sortBy( + Object.keys(boardLists).filter( + (listId) => + listId !== movedListId && + boardLists[listId].listType !== ListType.backlog && + boardLists[listId].listType !== ListType.closed, + ), + (i) => boardLists[i].position, + ); - movedList.position = newPosition; - listAtNewIndex.position += adjustmentValue; - commit(types.MOVE_LIST, { - movedList, - listAtNewIndex, - }); + const targetPosition = boardLists[displacedListId].position; + // When the dragged list moves left, displaced list should shift right. + const shiftOffset = Number(boardLists[movedListId].position < targetPosition); + const displacedListIndex = listIds.findIndex((listId) => listId === displacedListId); - dispatch('updateList', { listId, position: newPosition, backupList }); + commit( + types.MOVE_LISTS, + listIds + .slice(0, displacedListIndex + shiftOffset) + .concat([movedListId], listIds.slice(displacedListIndex + shiftOffset)) + .map((listId, index) => ({ listId, position: index })), + ); + dispatch('updateList', { listId: movedListId, position: targetPosition }); }, updateList: ( - { commit, state: { issuableType, boardItemsByListId = {} }, dispatch }, - { listId, position, collapsed, backupList }, + { state: { issuableType, boardItemsByListId = {} }, dispatch }, + { listId, position, collapsed }, ) => { gqlClient .mutate({ @@ -255,8 +323,7 @@ export default { }) .then(({ data }) => { if (data?.updateBoardList?.errors.length) { - commit(types.UPDATE_LIST_FAILURE, backupList); - return; + throw new Error(); } // Only fetch when board items havent been fetched on a collapsed list @@ -265,10 +332,19 @@ export default { } }) .catch(() => { - commit(types.UPDATE_LIST_FAILURE, backupList); + dispatch('handleUpdateListFailure'); }); }, + handleUpdateListFailure: ({ dispatch, commit }) => { + dispatch('fetchLists'); + + commit( + types.SET_ERROR, + s__('Boards|An error occurred while updating the board list. Please try again.'), + ); + }, + toggleListCollapsed: ({ commit }, { listId, collapsed }) => { commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); }, @@ -551,7 +627,7 @@ export default { mutation: issueSetLabelsMutation, variables: { input: { - iid: String(activeBoardItem.iid), + iid: input.iid || String(activeBoardItem.iid), addLabelIds: input.addLabelIds ?? [], removeLabelIds: input.removeLabelIds ?? [], projectPath: input.projectPath, @@ -564,7 +640,7 @@ export default { } commit(types.UPDATE_BOARD_ITEM_BY_ID, { - itemId: activeBoardItem.id, + itemId: getIdFromGraphQLId(data.updateIssue?.issue?.id) || activeBoardItem.id, prop: 'labels', value: data.updateIssue.issue.labels.nodes, }); diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 49c40c7776a..857b0912c57 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,8 +8,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -// eslint-disable-next-line import/no-deprecated -import { mergeUrlParams, urlParamsToObject, getUrlParamsArray } from '~/lib/utils/url_utility'; +import { mergeUrlParams, queryToObject, getUrlParamsArray } from '~/lib/utils/url_utility'; import { ListType, flashAnimationDuration } from '../constants'; import eventHub from '../eventhub'; import ListAssignee from '../models/assignee'; @@ -597,8 +596,7 @@ const boardsStore = { getListIssues(list, emptyIssues = true) { const data = { - // eslint-disable-next-line import/no-deprecated - ...urlParamsToObject(this.filter.path), + ...queryToObject(this.filter.path, { gatherArrays: true }), page: list.page, }; diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 38c54bc8c5d..31b78014525 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -10,8 +10,7 @@ export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; -export const MOVE_LIST = 'MOVE_LIST'; -export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; +export const MOVE_LISTS = 'MOVE_LISTS'; export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; @@ -19,6 +18,9 @@ export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; +export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST'; +export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; +export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE'; export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index a32a100fa11..668a3dbaa7e 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -1,7 +1,7 @@ -import { pull, union } from 'lodash'; +import { cloneDeep, pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import { formatIssue } from '../boards_util'; import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; @@ -103,15 +103,12 @@ export default { Vue.set(state.boardLists, list.id, list); }, - [mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => { - const { boardLists } = state; - Vue.set(boardLists, movedList.id, movedList); - Vue.set(boardLists, listAtNewIndex.id, listAtNewIndex); - }, - - [mutationTypes.UPDATE_LIST_FAILURE]: (state, backupList) => { - state.error = s__('Boards|An error occurred while updating the list. Please try again.'); - Vue.set(state, 'boardLists', backupList); + [mutationTypes.MOVE_LISTS]: (state, movedLists) => { + const updatedBoardList = movedLists.reduce((acc, { listId, position }) => { + acc[listId].position = position; + return acc; + }, cloneDeep(state.boardLists)); + Vue.set(state, 'boardLists', updatedBoardList); }, [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => { @@ -136,6 +133,20 @@ export default { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, + [mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) { + state.milestones = milestones; + state.milestonesLoading = false; + }, + + [mutationTypes.RECEIVE_MILESTONES_REQUEST](state) { + state.milestonesLoading = true; + }, + + [mutationTypes.RECEIVE_MILESTONES_FAILURE](state) { + state.milestonesLoading = false; + state.error = __('Failed to load milestones.'); + }, + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { const { listData, boardItems } = listItems; Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 7be5ae8b583..264a03ff39d 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -19,6 +19,8 @@ export default () => ({ boardConfig: {}, labelsLoading: false, labels: [], + milestones: [], + milestonesLoading: false, highlightedLists: [], selectedBoardItems: [], groupProjects: [], |