diff options
Diffstat (limited to 'app/assets/javascripts/boards')
32 files changed, 650 insertions, 216 deletions
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index e14a770411e..46f97e09385 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -54,6 +54,7 @@ export function formatListIssues(listIssues) { const listIssue = { ...i, id, + fullId: i.id, labels: i.labels?.nodes || [], assignees: i.assignees?.nodes || [], }; @@ -106,8 +107,8 @@ export function formatIssueInput(issueInput, boardConfig) { const { labels, assigneeId, milestoneId } = boardConfig; return { - milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null, ...issueInput, + milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null, labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])], assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])], }; diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue index 0f92e714752..b81edb4dfe6 100644 --- a/app/assets/javascripts/boards/components/board_blocked_icon.vue +++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; -import { IssueType } from '~/graphql_shared/constants'; +import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { truncate } from '~/lib/utils/text_utility'; import { __, n__, s__, sprintf } from '~/locale'; @@ -13,7 +13,7 @@ export default { }, }, graphQLIdType: { - [issuableTypes.issue]: IssueType, + [issuableTypes.issue]: TYPE_ISSUE, }, referenceFormatter: { [issuableTypes.issue]: (r) => r.split('/')[1], @@ -163,7 +163,7 @@ export default { ><span data-testid="popover-title">{{ blockedLabel }}</span></template > <template v-if="loading"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> </template> <template v-else> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 2f4e9044b9e..05b64ddc773 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,5 +1,12 @@ <script> -import { GlLabel, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { + GlLabel, + GlTooltip, + GlTooltipDirective, + GlIcon, + GlLoadingIcon, + GlSprintf, +} from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; @@ -16,6 +23,7 @@ import IssueTimeEstimate from './issue_time_estimate.vue'; export default { components: { + GlTooltip, GlLabel, GlLoadingIcon, GlIcon, @@ -25,6 +33,7 @@ export default { IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), BoardBlockedIcon, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -55,7 +64,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels', 'issuableType']), + ...mapState(['isShowingLabels', 'issuableType', 'allowSubEpics']), ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, @@ -99,6 +108,12 @@ export default { } return false; }, + shouldRenderEpicCountables() { + return this.isEpicBoard && this.item.hasIssues; + }, + shouldRenderEpicProgress() { + return this.totalWeight > 0; + }, showLabelFooter() { return this.isShowingLabels && this.item.labels.find(this.showLabel); }, @@ -115,6 +130,20 @@ export default { } return __('Blocked issue'); }, + totalEpicsCount() { + return this.item.descendantCounts.openedEpics + this.item.descendantCounts.closedEpics; + }, + totalIssuesCount() { + return this.item.descendantCounts.openedIssues + this.item.descendantCounts.closedIssues; + }, + totalWeight() { + return ( + this.item.descendantWeightSum.openedIssues + this.item.descendantWeightSum.closedIssues + ); + }, + totalProgress() { + return Math.round((this.item.descendantWeightSum.closedIssues / this.totalWeight) * 100); + }, }, methods: { ...mapActions(['performSearch', 'setError']), @@ -227,17 +256,93 @@ export default { {{ itemId }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> - <issue-due-date - v-if="item.dueDate" - :date="item.dueDate" - :closed="item.closed || Boolean(item.closedAt)" - /> - <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> - <issue-card-weight - v-if="validIssueWeight(item)" - :weight="item.weight" - @click="filterByWeight(item.weight)" - /> + <span v-if="shouldRenderEpicCountables" data-testid="epic-countables"> + <gl-tooltip :target="() => $refs.countBadge" data-testid="epic-countables-tooltip"> + <p v-if="allowSubEpics" class="gl-font-weight-bold gl-m-0"> + {{ __('Epics') }} • + <span class="gl-font-weight-normal"> + <gl-sprintf :message="__('%{openedEpics} open, %{closedEpics} closed')"> + <template #openedEpics>{{ item.descendantCounts.openedEpics }}</template> + <template #closedEpics>{{ item.descendantCounts.closedEpics }}</template> + </gl-sprintf> + </span> + </p> + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Issues') }} • + <span class="gl-font-weight-normal"> + <gl-sprintf :message="__('%{openedIssues} open, %{closedIssues} closed')"> + <template #openedIssues>{{ item.descendantCounts.openedIssues }}</template> + <template #closedIssues>{{ item.descendantCounts.closedIssues }}</template> + </gl-sprintf> + </span> + </p> + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Total weight') }} • + <span class="gl-font-weight-normal" data-testid="epic-countables-total-weight"> + {{ totalWeight }} + </span> + </p> + </gl-tooltip> + + <gl-tooltip + v-if="shouldRenderEpicProgress" + :target="() => $refs.progressBadge" + data-testid="epic-progress-tooltip" + > + <p class="gl-font-weight-bold gl-m-0"> + {{ __('Progress') }} • + <span class="gl-font-weight-normal" data-testid="epic-progress-tooltip-content"> + <gl-sprintf + :message="__('%{completedWeight} of %{totalWeight} weight completed')" + > + <template #completedWeight>{{ + item.descendantWeightSum.closedIssues + }}</template> + <template #totalWeight>{{ totalWeight }}</template> + </gl-sprintf> + </span> + </p> + </gl-tooltip> + + <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0"> + <span v-if="allowSubEpics" class="gl-mr-3"> + <gl-icon name="epic" /> + {{ totalEpicsCount }} + </span> + <span class="gl-mr-3" data-testid="epic-countables-counts-issues"> + <gl-icon name="issues" /> + {{ totalIssuesCount }} + </span> + <span class="gl-mr-3" data-testid="epic-countables-weight-issues"> + <gl-icon name="weight" /> + {{ totalWeight }} + </span> + </span> + + <span + v-if="shouldRenderEpicProgress" + ref="progressBadge" + class="issue-count-badge board-card-info gl-pl-0" + > + <span class="gl-mr-3" data-testid="epic-progress"> + <gl-icon name="progress" /> + {{ totalProgress }}% + </span> + </span> + </span> + <span v-if="!isEpicBoard"> + <issue-due-date + v-if="item.dueDate" + :date="item.dueDate" + :closed="item.closed || Boolean(item.closedAt)" + /> + <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> + <issue-card-weight + v-if="validIssueWeight(item)" + :weight="item.weight" + @click="filterByWeight(item.weight)" + /> + </span> </span> </div> <div class="board-card-assignee gl-display-flex"> diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index cc7262f3a39..69abf886ad7 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -41,7 +41,7 @@ export default { watch: { filterParams: { handler() { - if (this.list.id) { + if (this.list.id && !this.list.collapsed) { this.fetchItemsForList({ listId: this.list.id }); } }, diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index b770ac06e89..53b071aaed1 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -12,10 +12,8 @@ import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { BoardAddNewColumn, - BoardColumn: - gon.features?.graphqlBoardLists || gon.features?.epicBoards - ? BoardColumn - : BoardColumnDeprecated, + BoardColumn, + BoardColumnDeprecated, BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'), EpicBoardContentSidebar: () => import('ee_component/boards/components/epic_board_content_sidebar.vue'), @@ -38,11 +36,14 @@ export default { computed: { ...mapState(['boardLists', 'error', 'addColumnForm']), ...mapGetters(['isSwimlanesOn', 'isEpicBoard']), + useNewBoardColumnComponent() { + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard; + }, addColumnFormVisible() { return this.addColumnForm?.visible; }, boardListsToUse() { - return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard + return this.useNewBoardColumnComponent ? sortBy([...Object.values(this.boardLists)], 'position') : this.lists; }, @@ -65,6 +66,9 @@ export default { return this.canDragColumns ? options : {}; }, + boardColumnComponent() { + return this.useNewBoardColumnComponent ? BoardColumn : BoardColumnDeprecated; + }, }, methods: { ...mapActions(['moveList', 'unsetError']), @@ -102,7 +106,8 @@ export default { class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" @end="handleDragOnEnd" > - <board-column + <component + :is="boardColumnComponent" v-for="(list, index) in boardListsToUse" :key="index" ref="board" @@ -125,14 +130,9 @@ export default { <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" - class="boards-sidebar" data-testid="issue-boards-sidebar" /> - <epic-board-content-sidebar - v-else-if="isEpicBoard" - class="boards-sidebar" - data-testid="epic-boards-sidebar" - /> + <epic-board-content-sidebar v-else-if="isEpicBoard" data-testid="epic-boards-sidebar" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 16a8a9d253f..e014b82d362 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -1,20 +1,20 @@ <script> import { GlDrawer } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; import { mapState, mapActions, mapGetters } from 'vuex'; import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; -import { contentTop } from '~/lib/utils/common_utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; +import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { - headerHeight: `${contentTop()}px`, components: { GlDrawer, BoardSidebarTitle, @@ -25,8 +25,10 @@ export default { BoardSidebarLabelsSelect, SidebarSubscriptionsWidget, SidebarDropdownWidget, - BoardSidebarWeightInput: () => - import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), + SidebarTodoWidget, + MountingPortal, + SidebarWeightWidget: () => + import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), IterationSidebarDropdownWidget: () => import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, @@ -45,6 +47,7 @@ export default { default: false, }, }, + inheritAttrs: false, computed: { ...mapGetters([ 'isSidebarOpen', @@ -64,7 +67,12 @@ export default { }, }, methods: { - ...mapActions(['toggleBoardItem', 'setAssignees', 'setActiveItemConfidential']), + ...mapActions([ + 'toggleBoardItem', + 'setAssignees', + 'setActiveItemConfidential', + 'setActiveItemWeight', + ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, @@ -73,87 +81,105 @@ export default { </script> <template> - <gl-drawer - v-if="showSidebar" - :open="isSidebarOpen" - :header-height="$options.headerHeight" - @close="handleClose" - > - <template #header>{{ __('Issue details') }}</template> - <template #default> - <board-sidebar-title /> - <sidebar-assignees-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :initial-assignees="activeBoardItem.assignees" - :allow-multiple-assignees="multipleAssigneesFeatureAvailable" - @assignees-updated="setAssignees" - /> - <sidebar-dropdown-widget - v-if="epicFeatureAvailable" - :iid="activeBoardItem.iid" - issuable-attribute="epic" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - data-testid="sidebar-epic" - /> - <div> + <mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append> + <gl-drawer + v-if="showSidebar" + v-bind="$attrs" + :open="isSidebarOpen" + class="boards-sidebar gl-absolute" + @close="handleClose" + > + <template #title> + <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">{{ __('Issue details') }}</h2> + </template> + <template #header> + <sidebar-todo-widget + class="gl-mt-3" + :issuable-id="activeBoardItem.fullId" + :issuable-iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + /> + </template> + <template #default> + <board-sidebar-title /> + <sidebar-assignees-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :initial-assignees="activeBoardItem.assignees" + :allow-multiple-assignees="multipleAssigneesFeatureAvailable" + @assignees-updated="setAssignees" + /> <sidebar-dropdown-widget + v-if="epicFeatureAvailable" :iid="activeBoardItem.iid" - issuable-attribute="milestone" + issuable-attribute="epic" :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" :issuable-type="issuableType" - data-testid="sidebar-milestones" + data-testid="sidebar-epic" /> - <template v-if="!glFeatures.iterationCadences"> + <div> <sidebar-dropdown-widget - v-if="iterationFeatureAvailable" :iid="activeBoardItem.iid" - issuable-attribute="iteration" + issuable-attribute="milestone" :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" + :attr-workspace-path="projectPathForActiveIssue" :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - data-qa-selector="iteration_container" + data-testid="sidebar-milestones" /> - </template> - <template v-else> - <iteration-sidebar-dropdown-widget - v-if="iterationFeatureAvailable" - :iid="activeBoardItem.iid" - :workspace-path="projectPathForActiveIssue" - :attr-workspace-path="groupPathForActiveIssue" - :issuable-type="issuableType" - class="gl-mt-5" - data-testid="iteration-edit" - data-qa-selector="iteration_container" - /> - </template> - </div> - <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> - <sidebar-date-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - data-testid="sidebar-due-date" - /> - <board-sidebar-labels-select class="labels" /> - <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" /> - <sidebar-confidentiality-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - @confidentialityUpdated="setActiveItemConfidential($event)" - /> - <sidebar-subscriptions-widget - :iid="activeBoardItem.iid" - :full-path="fullPath" - :issuable-type="issuableType" - data-testid="sidebar-notifications" - /> - </template> - </gl-drawer> + <template v-if="!glFeatures.iterationCadences"> + <sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + issuable-attribute="iteration" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + /> + </template> + <template v-else> + <iteration-sidebar-dropdown-widget + v-if="iterationFeatureAvailable" + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :attr-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + data-testid="iteration-edit" + /> + </template> + </div> + <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> + <sidebar-date-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-due-date" + /> + <board-sidebar-labels-select class="labels" /> + <sidebar-weight-widget + v-if="weightFeatureAvailable" + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @weightUpdated="setActiveItemWeight($event)" + /> + <sidebar-confidentiality-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @confidentialityUpdated="setActiveItemConfidential($event)" + /> + <sidebar-subscriptions-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-notifications" + /> + </template> + </gl-drawer> + </mounting-portal> </template> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue index 13388f02f1f..cfd6b21fa66 100644 --- a/app/assets/javascripts/boards/components/board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -27,7 +27,7 @@ export default { }, computed: { urlParams() { - const { authorUsername, labelName, search } = this.filterParams; + const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; let notParams = {}; if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { @@ -35,6 +35,7 @@ export default { { 'not[label_name][]': this.filterParams.not.labelName, 'not[author_username]': this.filterParams.not.authorUsername, + 'not[assignee_username]': this.filterParams.not.assigneeUsername, }, undefined, ); @@ -44,6 +45,7 @@ export default { ...notParams, author_username: authorUsername, 'label_name[]': labelName, + assignee_username: assigneeUsername, search, }; }, @@ -62,7 +64,7 @@ export default { this.performSearch(); }, getFilteredSearchValue() { - const { authorUsername, labelName, search } = this.filterParams; + const { authorUsername, labelName, assigneeUsername, search } = this.filterParams; const filteredSearchValue = []; if (authorUsername) { @@ -72,6 +74,13 @@ export default { }); } + if (assigneeUsername) { + filteredSearchValue.push({ + type: 'assignee_username', + value: { data: assigneeUsername, operator: '=' }, + }); + } + if (labelName?.length) { filteredSearchValue.push( ...labelName.map((label) => ({ @@ -88,6 +97,13 @@ export default { }); } + if (this.filterParams['not[assigneeUsername]']) { + filteredSearchValue.push({ + type: 'assignee_username', + value: { data: this.filterParams['not[assigneeUsername]'], operator: '!=' }, + }); + } + if (this.filterParams['not[labelName]']) { filteredSearchValue.push( ...this.filterParams['not[labelName]'].map((label) => ({ @@ -121,6 +137,9 @@ export default { case 'author_username': filterParams.authorUsername = filter.value.data; break; + case 'assignee_username': + filterParams.assigneeUsername = filter.value.data; + break; case 'label_name': labels.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 aa75a0d68f5..386ed6bd0a1 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -2,9 +2,9 @@ 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 { convertToGraphQLId } from '~/graphql_shared/utils'; -import { getParameterByName } from '~/lib/utils/common_utils'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { getParameterByName, visitUrl } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { fullLabelId, fullBoardId } from '../boards_util'; import { formType } from '../constants'; @@ -188,21 +188,19 @@ export default { }; }, issueBoardScopeMutationVariables() { - /* eslint-disable @gitlab/require-i18n-strings */ return { weight: this.board.weight, assigneeId: this.board.assignee?.id - ? convertToGraphQLId('User', this.board.assignee.id) + ? convertToGraphQLId(TYPE_USER, this.board.assignee.id) : null, milestoneId: this.board.milestone?.id || this.board.milestone?.id === 0 - ? convertToGraphQLId('Milestone', this.board.milestone.id) + ? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id) : null, iterationId: this.board.iteration_id - ? convertToGraphQLId('Iteration', this.board.iteration_id) + ? convertToGraphQLId(TYPE_ITERATION, this.board.iteration_id) : null, }; - /* eslint-enable @gitlab/require-i18n-strings */ }, boardScopeMutationVariables() { return { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 81740b5cd17..8dca6be853f 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,6 +6,7 @@ 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 eventHub from '../eventhub'; import BoardCard from './board_card.vue'; import BoardNewIssue from './board_new_issue.vue'; @@ -21,6 +22,7 @@ export default { components: { BoardCard, BoardNewIssue, + BoardNewEpic: () => import('ee_component/boards/components/board_new_epic.vue'), GlLoadingIcon, GlIntersectionObserver, }, @@ -49,6 +51,7 @@ export default { scrollOffset: 250, showCount: false, showIssueForm: false, + showEpicForm: false, }; }, computed: { @@ -64,6 +67,9 @@ export default { issuableType: this.isEpicBoard ? 'epics' : 'issues', }); }, + toggleFormEventPrefix() { + return this.isEpicBoard ? toggleFormEventPrefix.epic : toggleFormEventPrefix.issue; + }, boardItemsSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount; }, @@ -76,6 +82,12 @@ export default { loadingMore() { return this.listsFlags[this.list.id]?.isLoadingMore; }, + epicCreateFormVisible() { + return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm; + }, + issueCreateFormVisible() { + 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; @@ -116,9 +128,10 @@ export default { 'list.id': { handler(id, oldVal) { if (id) { - eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$on(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm); eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop); - eventHub.$off(`toggle-issue-form-${oldVal}`, this.toggleForm); + + eventHub.$off(`${this.toggleFormEventPrefix}${oldVal}`, this.toggleForm); eventHub.$off(`scroll-board-list-${oldVal}`, this.scrollToTop); } }, @@ -126,7 +139,7 @@ export default { }, }, beforeDestroy() { - eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); + eventHub.$off(`${this.toggleFormEventPrefix}${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); }, methods: { @@ -147,7 +160,11 @@ export default { this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { - this.showIssueForm = !this.showIssueForm; + if (this.isEpicBoard) { + this.showEpicForm = !this.showEpicForm; + } else { + this.showIssueForm = !this.showIssueForm; + } }, onReachingListBottom() { if (!this.loadingMore && this.hasNextPage) { @@ -225,9 +242,10 @@ export default { :aria-label="$options.i18n.loading" data-testid="board_list_loading" > - <gl-loading-icon /> + <gl-loading-icon size="sm" /> </div> - <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" /> + <board-new-issue v-if="issueCreateFormVisible" :list="list" /> + <board-new-epic v-if="epicCreateFormVisible" :list="list" /> <component :is="treeRootWrapper" v-show="!loading" @@ -255,6 +273,7 @@ export default { <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> <gl-loading-icon v-if="loadingMore" + size="sm" :label="$options.i18n.loadingMoreboardItems" data-testid="count-loading-icon" /> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 9b3e7e1547d..fabaf7a85f5 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -429,7 +429,7 @@ export default { data-qa-selector="board_list_cards_area" > <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> - <gl-loading-icon /> + <gl-loading-icon size="sm" /> </div> <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> <ul @@ -450,7 +450,7 @@ export default { :disabled="disabled" /> <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1"> - <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" /> + <gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" /> <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bf8396f52a6..8d5f0f7eb89 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -16,13 +16,14 @@ import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import AccessorUtilities from '../../lib/utils/accessor'; -import { inactiveId, LIST, ListType } from '../constants'; +import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants'; import eventHub from '../eventhub'; import ItemCount from './item_count.vue'; export default { i18n: { newIssue: __('New issue'), + newEpic: s__('Boards|New epic'), listSettings: __('List settings'), expand: s__('Boards|Expand'), collapse: s__('Boards|Collapse'), @@ -72,7 +73,7 @@ export default { }, computed: { ...mapState(['activeId']), - ...mapGetters(['isEpicBoard']), + ...mapGetters(['isEpicBoard', 'isSwimlanesOn']), isLoggedIn() { return Boolean(this.currentUserId); }, @@ -102,7 +103,7 @@ export default { }, showListHeaderActions() { if (this.isLoggedIn) { - return this.isNewIssueShown || this.isSettingsShown; + return this.isNewIssueShown || this.isNewEpicShown || this.isSettingsShown; } return false; }, @@ -124,6 +125,9 @@ export default { isNewIssueShown() { return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; }, + isNewEpicShown() { + return this.isEpicBoard && this.listType !== ListType.closed; + }, isSettingsShown() { return ( this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed @@ -165,7 +169,17 @@ export default { }, showNewIssueForm() { - eventHub.$emit(`toggle-issue-form-${this.list.id}`); + if (this.isSwimlanesOn) { + eventHub.$emit('open-unassigned-lane'); + this.$nextTick(() => { + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + }); + } else { + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); + } + }, + showNewEpicForm() { + eventHub.$emit(`${toggleFormEventPrefix.epic}${this.list.id}`); }, toggleExpanded() { const collapsed = !this.list.collapsed; @@ -342,7 +356,7 @@ export default { <!-- EE end --> <div - class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500" data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, @@ -380,6 +394,17 @@ export default { /> <gl-button + v-if="isNewEpicShown" + v-show="!list.collapsed" + v-gl-tooltip.hover + :aria-label="$options.i18n.newEpic" + :title="$options.i18n.newEpic" + class="no-drag" + icon="plus" + @click="showNewEpicForm" + /> + + <gl-button v-if="isSettingsShown" ref="settingsBtn" v-gl-tooltip.hover diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index a63b49f9508..caeecb25227 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -4,13 +4,13 @@ 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 ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', i18n: { - submit: __('Create issue'), cancel: __('Cancel'), }, components: { @@ -32,7 +32,15 @@ export default { }, computed: { ...mapState(['selectedProject']), - ...mapGetters(['isGroupBoard']), + ...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'); + }, disabled() { if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; @@ -50,9 +58,7 @@ export default { }, methods: { ...mapActions(['addListNewIssue']), - submit(e) { - e.preventDefault(); - + submit() { const { title } = this; const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; @@ -76,7 +82,7 @@ export default { }, reset() { this.title = ''; - eventHub.$emit(`toggle-issue-form-${this.list.id}`); + eventHub.$emit(`${toggleFormEventPrefix.issue}${this.list.id}`); }, }, }; @@ -85,7 +91,7 @@ export default { <template> <div class="board-new-issue-form"> <div class="board-card position-relative p-3 rounded"> - <form ref="submitForm" @submit="submit"> + <form ref="submitForm" @submit.prevent="submit"> <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> <input :id="inputFieldId" @@ -96,7 +102,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard && !isEpicBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" @@ -106,7 +112,7 @@ export default { category="primary" type="submit" > - {{ $options.i18n.submit }} + {{ submitButtonTitle }} </gl-button> <gl-button ref="cancelButton" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 75975c77df5..c089a6a39af 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui'; +import { MountingPortal } from 'portal-vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { LIST, ListType, ListTypeTitles } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; @@ -9,14 +10,13 @@ import eventHub from '~/sidebar/event_hub'; import Tracking from '~/tracking'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. export default { - headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px', listSettingsText: __('List settings'), components: { GlButton, GlDrawer, GlLabel, + MountingPortal, BoardSettingsSidebarWipLimit: () => import('ee_component/boards/components/board_settings_wip_limit.vue'), BoardSettingsListTypes: () => @@ -24,6 +24,7 @@ export default { }, mixins: [glFeatureFlagMixin(), Tracking.mixin()], inject: ['canAdminList'], + inheritAttrs: false, data() { return { ListType, @@ -86,43 +87,45 @@ export default { </script> <template> - <gl-drawer - v-if="showSidebar" - class="js-board-settings-sidebar" - :open="isSidebarOpen" - :header-height="$options.headerHeight" - @close="unsetActiveId" - > - <template #header>{{ $options.listSettingsText }}</template> - <template v-if="isSidebarOpen"> - <div v-if="boardListType === ListType.label"> - <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> - <gl-label - :title="activeListLabel.title" - :background-color="activeListLabel.color" - :scoped="showScopedLabels(activeListLabel)" - /> - </div> + <mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append> + <gl-drawer + v-if="showSidebar" + v-bind="$attrs" + class="js-board-settings-sidebar gl-absolute" + :open="isSidebarOpen" + @close="unsetActiveId" + > + <template #title>{{ $options.listSettingsText }}</template> + <template v-if="isSidebarOpen"> + <div v-if="boardListType === ListType.label"> + <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> + <gl-label + :title="activeListLabel.title" + :background-color="activeListLabel.color" + :scoped="showScopedLabels(activeListLabel)" + /> + </div> - <board-settings-list-types - v-else - :active-list="activeList" - :board-list-type="boardListType" - /> - <board-settings-sidebar-wip-limit - v-if="isWipLimitsOn" - :max-issue-count="activeList.maxIssueCount" - /> - <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4"> - <gl-button - variant="danger" - category="secondary" - icon="remove" - data-testid="remove-list" - @click.stop="deleteBoard" - >{{ __('Remove list') }} - </gl-button> - </div> - </template> - </gl-drawer> + <board-settings-list-types + v-else + :active-list="activeList" + :board-list-type="boardListType" + /> + <board-settings-sidebar-wip-limit + v-if="isWipLimitsOn" + :max-issue-count="activeList.maxIssueCount" + /> + <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4"> + <gl-button + variant="danger" + category="secondary" + icon="remove" + data-testid="remove-list" + @click.stop="deleteBoard" + >{{ __('Remove list') }} + </gl-button> + </div> + </template> + </gl-drawer> + </mounting-portal> </template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 55bc91cbcff..21a34182369 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -105,7 +105,7 @@ export default Vue.extend({ closeSidebar() { this.detail.issue = {}; }, - setAssignees(assignees) { + setAssignees({ assignees }) { boardsStore.detail.issue.setAssignees(assignees); }, showScopedLabels(label) { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 5124467136e..98027917221 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -327,7 +327,7 @@ export default { :class="scrollFadeClass" ></div> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <div v-if="canAdminBoard"> <gl-dropdown-divider /> diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue index 85c7b27336b..c1536dff2c6 100644 --- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue +++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue @@ -316,7 +316,7 @@ export default { :class="scrollFadeClass" ></div> - <gl-loading-icon v-if="loading" /> + <gl-loading-icon v-if="loading" size="sm" /> <div v-if="canAdminBoard"> <gl-dropdown-divider /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue new file mode 100644 index 00000000000..d8dac17d326 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -0,0 +1,102 @@ +<script> +import BoardFilteredSearch from '~/boards/components/board_filtered_search.vue'; +import issueBoardFilters from '~/boards/issue_board_filters'; +import { TYPE_USER } from '~/graphql_shared/constants'; +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'; + +export default { + i18n: { + search: __('Search'), + label: __('Label'), + author: __('Author'), + assignee: __('Assignee'), + is: __('is'), + isNot: __('is not'), + }, + components: { BoardFilteredSearch }, + props: { + fullPath: { + type: String, + required: true, + }, + boardType: { + type: String, + required: true, + }, + }, + computed: { + tokens() { + const { label, is, isNot, author, assignee } = this.$options.i18n; + const { fetchAuthors, fetchLabels } = issueBoardFilters( + this.$apollo, + this.fullPath, + this.boardType, + ); + + return [ + { + icon: 'labels', + title: label, + type: 'label_name', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + token: LabelToken, + unique: false, + symbol: '~', + fetchLabels, + }, + { + icon: 'pencil', + title: author, + type: 'author_username', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + symbol: '@', + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: this.preloadedAuthors(), + }, + { + icon: 'user', + title: assignee, + type: 'assignee_username', + operators: [ + { value: '=', description: is }, + { value: '!=', description: isNot }, + ], + token: AuthorToken, + unique: true, + fetchAuthors, + preloadedAuthors: this.preloadedAuthors(), + }, + ]; + }, + }, + methods: { + preloadedAuthors() { + return gon?.current_user_id + ? [ + { + id: convertToGraphQLId(TYPE_USER, gon.current_user_id), + name: gon.current_user_fullname, + username: gon.current_username, + avatarUrl: gon.current_user_avatar_url, + }, + ] + : []; + }, + }, +}; +</script> + +<template> + <board-filtered-search data-testid="issue-board-filtered-search" :tokens="tokens" /> +</template> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 2fd16f06455..6eb1dbfb46a 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -53,7 +53,9 @@ export default function initNewListDropdown() { data(term, callback) { const reqFailed = () => { $dropdownToggle.data('bs.dropdown').hide(); - flash(__('Error fetching labels.')); + createFlash({ + message: __('Error fetching labels.'), + }); }; if (store.getters.shouldUseGraphQL) { diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 77b6af77652..1412411c275 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -126,7 +126,7 @@ export default { v-show="groupProjectsFlags.isLoading" data-testid="dropdown-text-loading-icon" > - <gl-loading-icon class="gl-mx-auto" /> + <gl-loading-icon class="gl-mx-auto" size="sm" /> </gl-dropdown-text> <gl-dropdown-text v-if="isFetchResultEmpty && !groupProjectsFlags.isLoading" diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue index afe161d9c54..fc95ba0461d 100644 --- a/app/assets/javascripts/boards/components/project_select_deprecated.vue +++ b/app/assets/javascripts/boards/components/project_select_deprecated.vue @@ -136,7 +136,7 @@ export default { {{ project.namespacedName }} </gl-dropdown-item> <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon"> - <gl-loading-icon class="gl-mx-auto" /> + <gl-loading-icon class="gl-mx-auto" size="sm" /> </gl-dropdown-text> <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> 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 352a25ef6d9..84802650dad 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -93,7 +93,7 @@ export default { <slot name="title"> <span data-testid="title">{{ title }}</span> </slot> - <gl-loading-icon v-if="loading" inline class="gl-ml-2" /> + <gl-loading-icon v-if="loading" size="sm" inline class="gl-ml-2" /> </span> <gl-button v-if="canUpdate" diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 80a8fc99895..21ef70582a4 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -45,6 +45,11 @@ export const formType = { edit: 'edit', }; +export const toggleFormEventPrefix = { + epic: 'toggle-epic-form-', + issue: 'toggle-issue-form-', +}; + export const inactiveId = 0; export const ISSUABLE = 'issuable'; diff --git a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql index 3c5f4b3e3bd..70eb1dfbf7e 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql @@ -1,6 +1,7 @@ mutation issueSetLabels($input: UpdateIssueInput!) { updateIssue(input: $input) { issue { + id labels { nodes { id diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index fb347ce852d..de7c8a3bd6b 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,4 +1,5 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import PortalVue from 'portal-vue'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapActions, mapGetters } from 'vuex'; @@ -24,6 +25,7 @@ import '~/boards/filters/due_date_filters'; import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; +import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import toggleFocusMode from '~/boards/toggle_focus'; @@ -41,6 +43,7 @@ import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); +Vue.use(PortalVue); const fragmentMatcher = new IntrospectionFragmentMatcher({ introspectionQueryResultData, @@ -76,6 +79,10 @@ export default () => { issueBoardsApp.$destroy(true); } + if (gon?.features?.issueBoardsFilteredSearch) { + initBoardsFilteredSearch(apolloProvider); + } + if (!gon?.features?.graphqlBoardLists) { boardsStore.create(); boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); @@ -182,9 +189,14 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - - this.filterManager.setup(); + if (!gon?.features?.issueBoardsFilteredSearch) { + this.filterManager = new FilteredSearchBoards( + boardsStore.filter, + true, + boardsStore.cantEdit, + ); + this.filterManager.setup(); + } this.performSearch(); @@ -304,9 +316,11 @@ export default () => { // eslint-disable-next-line no-new, @gitlab/no-runtime-template-compiler new Vue({ el: document.getElementById('js-add-list'), - data: { - filters: boardsStore.state.filters, - ...getMilestoneTitle($boardApp), + data() { + return { + filters: boardsStore.state.filters, + ...getMilestoneTitle($boardApp), + }; }, mounted() { initNewListDropdown(); diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js new file mode 100644 index 00000000000..699d7e12de4 --- /dev/null +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -0,0 +1,47 @@ +import groupBoardMembers from '~/boards/graphql/group_board_members.query.graphql'; +import projectBoardMembers from '~/boards/graphql/project_board_members.query.graphql'; +import { BoardType } from './constants'; +import boardLabels from './graphql/board_labels.query.graphql'; + +export default function issueBoardFilters(apollo, fullPath, boardType) { + const isGroupBoard = boardType === BoardType.group; + const isProjectBoard = boardType === BoardType.project; + const transformLabels = ({ data }) => { + return isGroupBoard ? data.group?.labels.nodes || [] : data.project?.labels.nodes || []; + }; + + const boardAssigneesQuery = () => { + return isGroupBoard ? groupBoardMembers : projectBoardMembers; + }; + + const fetchAuthors = (authorsSearchTerm) => { + return apollo + .query({ + query: boardAssigneesQuery(), + variables: { + fullPath, + search: authorsSearchTerm, + }, + }) + .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); + }; + + const fetchLabels = (labelSearchTerm) => { + return apollo + .query({ + query: boardLabels, + variables: { + fullPath, + searchTerm: labelSearchTerm, + isGroup: isGroupBoard, + isProject: isProjectBoard, + }, + }) + .then(transformLabels); + }; + + return { + fetchLabels, + fetchAuthors, + }; +} diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index a95d749d71c..1bb0ee5b7e3 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,6 +1,6 @@ /* global DocumentTouch */ -import sortableConfig from 'ee_else_ce/sortable/sortable_config'; +import sortableConfig from '~/sortable/sortable_config'; export function sortableStart() { document.body.classList.add('is-dragging'); diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 6c6e2522d92..ab24532d87f 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,5 +1,5 @@ /* eslint-disable class-methods-use-this */ -import { deprecatedCreateFlash as flash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import boardsStore from '../stores/boards_store'; import ListAssignee from './assignee'; @@ -127,7 +127,11 @@ class List { moveBeforeId, moveAfterId, }) - .catch(() => flash(__('Something went wrong while moving issues.'))); + .catch(() => + createFlash({ + message: __('Something went wrong while moving issues.'), + }), + ); } updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) { @@ -145,7 +149,11 @@ class List { moveBeforeId, moveAfterId, }) - .catch(() => flash(__('Something went wrong while moving issues.'))); + .catch(() => + createFlash({ + message: __('Something went wrong while moving issues.'), + }), + ); } findIssue(id) { diff --git a/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js new file mode 100644 index 00000000000..7732091ef34 --- /dev/null +++ b/app/assets/javascripts/boards/mount_filtered_search_issue_boards.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue'; +import store from '~/boards/stores'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { queryToObject } from '~/lib/utils/url_utility'; + +export default (apolloProvider) => { + const el = document.getElementById('js-issue-board-filtered-search'); + const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true }); + + const initialFilterParams = { + ...convertObjectPropsToCamelCase(rawFilterParams, {}), + }; + + if (!el) { + return null; + } + + return new Vue({ + el, + provide: { + initialFilterParams, + }, + store, // TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/324094 + apolloProvider, + render: (createElement) => + createElement(IssueBoardFilteredSearch, { + props: { fullPath: store.state?.fullPath || '', boardType: store.state?.boardType || '' }, + }), + }); +}; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index d4893f9eca7..0f1b72146c9 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -18,7 +18,9 @@ import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create 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, urlParamsToObject } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +// eslint-disable-next-line import/no-deprecated +import { urlParamsToObject } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { formatBoardLists, @@ -74,6 +76,7 @@ export default { performSearch({ dispatch }) { dispatch( 'setFilters', + // eslint-disable-next-line import/no-deprecated convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)), ); @@ -170,8 +173,9 @@ export default { addList: ({ commit, dispatch, getters }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); + dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog).id, + listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, }); }, @@ -237,7 +241,7 @@ export default { }, updateList: ( - { commit, state: { issuableType } }, + { commit, state: { issuableType, boardItemsByListId = {} }, dispatch }, { listId, position, collapsed, backupList }, ) => { gqlClient @@ -252,6 +256,12 @@ export default { .then(({ data }) => { if (data?.updateBoardList?.errors.length) { commit(types.UPDATE_LIST_FAILURE, backupList); + return; + } + + // Only fetch when board items havent been fetched on a collapsed list + if (!boardItemsByListId[listId]) { + dispatch('fetchItemsForList', { listId }); } }) .catch(() => { @@ -285,7 +295,7 @@ export default { commit(types.REMOVE_LIST_FAILURE, listsBackup); } else { dispatch('fetchItemsForList', { - listId: getters.getListByTitle(ListTypeTitles.backlog).id, + listId: getters.getListByTitle(ListTypeTitles.backlog)?.id, }); } }, @@ -296,6 +306,8 @@ export default { }, fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + if (!listId) return null; + if (!fetchNext) { commit(types.RESET_ITEMS_FOR_LIST, listId); } @@ -469,11 +481,11 @@ export default { } }, - setAssignees: ({ commit, getters }, assigneeUsernames) => { + setAssignees: ({ commit }, { id, assignees }) => { commit('UPDATE_BOARD_ITEM_BY_ID', { - itemId: getters.activeBoardItem.id, + itemId: id, prop: 'assignees', - value: assigneeUsernames, + value: assignees, }); }, @@ -701,4 +713,7 @@ export default { unsetError: ({ commit }) => { commit(types.SET_ERROR, undefined); }, + + // EE action needs CE empty equivalent + setActiveItemWeight: () => {}, }; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 092f81ad279..49c40c7776a 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -7,13 +7,9 @@ import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createDefaultClient from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; -import { - urlParamsToObject, - getUrlParamsArray, - parseBoolean, - convertObjectPropsToCamelCase, -} from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +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 { ListType, flashAnimationDuration } from '../constants'; import eventHub from '../eventhub'; import ListAssignee from '../models/assignee'; @@ -601,6 +597,7 @@ const boardsStore = { getListIssues(list, emptyIssues = true) { const data = { + // eslint-disable-next-line import/no-deprecated ...urlParamsToObject(this.filter.path), page: list.page, }; diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index b61ecc5ccb6..140c9ef7ac4 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -16,7 +16,7 @@ export default { }, activeBoardItem: (state) => { - return state.boardItems[state.activeId] || {}; + return state.boardItems[state.activeId] || { iid: '', id: '', fullId: '' }; }, groupPathForActiveIssue: (_, getters) => { diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 6cd0a62657e..a32a100fa11 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -35,13 +35,23 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullBoardId, fullPath, boardConfig, issuableType } = data; + const { + allowSubEpics, + boardConfig, + boardId, + boardType, + disabled, + fullBoardId, + fullPath, + issuableType, + } = data; + state.allowSubEpics = allowSubEpics; + state.boardConfig = boardConfig; state.boardId = boardId; - state.fullBoardId = fullBoardId; - state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; - state.boardConfig = boardConfig; + state.fullBoardId = fullBoardId; + state.fullPath = fullPath; state.issuableType = issuableType; }, |