diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-19 11:27:35 +0300 |
commit | 7e9c479f7de77702622631cff2628a9c8dcbc627 (patch) | |
tree | c8f718a08e110ad7e1894510980d2155a6549197 /app/assets/javascripts/boards/components | |
parent | e852b0ae16db4052c1c567d9efa4facc81146e88 (diff) |
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/boards/components')
23 files changed, 1065 insertions, 151 deletions
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue new file mode 100644 index 00000000000..c81f171af2b --- /dev/null +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -0,0 +1,178 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; +import searchUsers from '~/boards/queries/users_search.query.graphql'; + +export default { + noSearchDelay: 0, + searchDelay: 250, + i18n: { + unassigned: __('Unassigned'), + assignee: __('Assignee'), + assignees: __('Assignees'), + assignTo: __('Assign to'), + }, + components: { + BoardEditableItem, + IssuableAssignees, + MultiSelectDropdown, + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, + }, + data() { + return { + search: '', + participants: [], + selected: this.$store.getters.activeIssue.assignees, + }; + }, + apollo: { + participants: { + query() { + return this.isSearchEmpty ? getIssueParticipants : searchUsers; + }, + variables() { + if (this.isSearchEmpty) { + return { + id: `gid://gitlab/Issue/${this.activeIssue.iid}`, + }; + } + + return { + search: this.search, + }; + }, + update(data) { + if (this.isSearchEmpty) { + return data.issue?.participants?.nodes || []; + } + + return data.users?.nodes || []; + }, + debounce() { + const { noSearchDelay, searchDelay } = this.$options; + + return this.isSearchEmpty ? noSearchDelay : searchDelay; + }, + }, + }, + computed: { + ...mapGetters(['activeIssue']), + assigneeText() { + return n__('Assignee', '%d Assignees', this.selected.length); + }, + unSelectedFiltered() { + return this.participants.filter(({ username }) => { + return !this.selectedUserNames.includes(username); + }); + }, + selectedIsEmpty() { + return this.selected.length === 0; + }, + selectedUserNames() { + return this.selected.map(({ username }) => username); + }, + isSearchEmpty() { + return this.search === ''; + }, + }, + methods: { + ...mapActions(['setAssignees']), + clearSelected() { + this.selected = []; + }, + selectAssignee(name) { + if (name === undefined) { + this.clearSelected(); + return; + } + + this.selected = this.selected.concat(name); + }, + unselect(name) { + this.selected = this.selected.filter(user => user.username !== name); + }, + saveAssignees() { + this.setAssignees(this.selectedUserNames); + }, + isChecked(id) { + return this.selectedUserNames.includes(id); + }, + }, +}; +</script> + +<template> + <board-editable-item :title="assigneeText" @close="saveAssignees"> + <template #collapsed> + <issuable-assignees :users="activeIssue.assignees" /> + </template> + + <template #default> + <multi-select-dropdown + class="w-100" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + > + <template #search> + <gl-search-box-by-type v-model.trim="search" /> + </template> + <template #items> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + data-testid="unassign" + class="mt-2" + @click="selectAssignee()" + >{{ $options.i18n.unassigned }}</gl-dropdown-item + > + <gl-dropdown-divider data-testid="unassign-divider" /> + <gl-dropdown-item + v-for="item in selected" + :key="item.id" + :is-checked="isChecked(item.username)" + @click="unselect(item.username)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="item.name" + :sub-label="item.username" + :src="item.avatarUrl || item.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> + <gl-dropdown-item + v-for="unselectedUser in unSelectedFiltered" + :key="unselectedUser.id" + :data-testid="`item_${unselectedUser.name}`" + @click="selectAssignee(unselectedUser)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="unselectedUser.name" + :sub-label="unselectedUser.username" + :src="unselectedUser.avatarUrl || unselectedUser.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + </template> + </multi-select-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index 072dd87861a..f796acd2303 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -44,9 +44,6 @@ export default { multiSelectVisible() { return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; }, - canMultiSelect() { - return gon.features && gon.features.multiSelectBoard; - }, }, methods: { mouseDown() { @@ -59,7 +56,7 @@ export default { // Don't do anything if this happened on a no trigger element if (e.target.classList.contains('js-no-trigger')) return; - const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); + const isMultiSelect = e.ctrlKey || e.metaKey; if (this.showDetail || isMultiSelect) { this.showDetail = false; diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 9295065b7b7..cb93340bcf8 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,14 +1,10 @@ <script> -import { mapGetters, mapActions } from 'vuex'; +// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards import Sortable from 'sortablejs'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; -import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardList from './board_list.vue'; -import BoardListNew from './board_list_new.vue'; import boardsStore from '../stores/boards_store'; -import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; import { ListType } from '../constants'; @@ -16,12 +12,8 @@ export default { components: { BoardPromotionState: EmptyComponent, BoardListHeader, - BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, + BoardList, }, - directives: { - Tooltip, - }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -50,44 +42,25 @@ export default { }; }, computed: { - ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { return this.list.type !== ListType.promotion; }, - uniqueKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; - }, listIssues() { - if (!this.glFeatures.graphqlBoardLists) { - return this.list.issues; - } - return this.getIssues(this.list.id); - }, - shouldFetchIssues() { - return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank; + return this.list.issues; }, }, watch: { filter: { handler() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } else { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); - } + this.list.page = 1; + this.list.getIssues(true).catch(() => { + // TODO: handle request error + }); }, deep: true, }, }, mounted() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } - const instance = this; const sortableOptions = getBoardSortableDefaultOptions({ @@ -113,12 +86,6 @@ export default { Sortable.create(this.$el.parentNode, sortableOptions); }, - methods: { - ...mapActions(['fetchIssuesForList']), - showListNewIssueForm(listId) { - eventHub.$emit('showForm', listId); - }, - }, }; </script> @@ -131,7 +98,7 @@ export default { 'board-type-assignee': list.type === 'assignee', }" :data-id="list.id" - class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" data-qa-selector="board_list" > <div diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue new file mode 100644 index 00000000000..8a59355eb83 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -0,0 +1,94 @@ +<script> +import { mapGetters, mapActions, mapState } from 'vuex'; +import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; +import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state'; +import BoardList from './board_list_new.vue'; +import { ListType } from '../constants'; + +export default { + components: { + BoardPromotionState, + BoardListHeader, + BoardList, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + }, + computed: { + ...mapState(['filterParams']), + ...mapGetters(['getIssuesByList']), + showBoardListAndBoardInfo() { + return this.list.type !== ListType.promotion; + }, + listIssues() { + return this.getIssuesByList(this.list.id); + }, + shouldFetchIssues() { + return this.list.type !== ListType.blank; + }, + }, + watch: { + filterParams: { + handler() { + if (this.shouldFetchIssues) { + this.fetchIssuesForList({ listId: this.list.id }); + } + }, + deep: true, + immediate: true, + }, + }, + methods: { + ...mapActions(['fetchIssuesForList']), + // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515 + }, +}; +</script> + +<template> + <div + :class="{ + 'is-draggable': !list.preset, + 'is-expandable': list.isExpandable, + 'is-collapsed': !list.isExpanded, + 'board-type-assignee': list.type === 'assignee', + }" + :data-id="list.id" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + data-qa-selector="board_list" + > + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + > + <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + <board-list + v-if="showBoardListAndBoardInfo" + ref="board-list" + :disabled="disabled" + :issues="listIssues" + :list="list" + /> + + <!-- Will be only available in EE --> + <board-promotion-state v-if="list.id === 'promotion'" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index ad3d653b905..754b00b54e0 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -43,7 +43,7 @@ export default { <template> <div class="append-bottom-20"> - <label class="form-section-title label-bold" for="board-new-name"> + <label class="label-bold gl-font-lg" for="board-new-name"> {{ __('List options') }} </label> <p class="text-secondary gl-mb-3"> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 2515f471379..92976574efb 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,13 +1,14 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { sortBy } from 'lodash'; -import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import { GlAlert } from '@gitlab/ui'; +import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import BoardColumnNew from './board_column_new.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - BoardColumn, + BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -38,12 +39,11 @@ export default { }, mounted() { if (this.glFeatures.graphqlBoardLists) { - this.fetchLists(); this.showPromotionList(); } }, methods: { - ...mapActions(['fetchLists', 'showPromotionList']), + ...mapActions(['showPromotionList']), }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 793c594cf16..e4ef3600ff9 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -196,9 +196,7 @@ export default { <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> - <label class="form-section-title label-bold" for="board-new-name">{{ - __('Title') - }}</label> + <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label> <input id="board-new-name" ref="name" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index d01df44e7e4..53989e2d9de 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -16,9 +16,7 @@ import { // This component is being replaced in favor of './board_list_new.vue' for GraphQL boards -if (gon.features && gon.features.multiSelectBoard) { - Sortable.mount(new MultiDrag()); -} +Sortable.mount(new MultiDrag()); export default { name: 'BoardList', @@ -100,12 +98,11 @@ export default { mounted() { // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue // https://gitlab.com/gitlab-org/gitlab/-/issues/218164 - const multiSelectOpts = {}; - if (gon.features && gon.features.multiSelectBoard) { - multiSelectOpts.multiDrag = true; - multiSelectOpts.selectedClass = 'js-multi-select'; - multiSelectOpts.animation = 500; - } + const multiSelectOpts = { + multiDrag: true, + selectedClass: 'js-multi-select', + animation: 500, + }; const options = getBoardSortableDefaultOptions({ scroll: true, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bb9a1b79d91..d85ba2038a7 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -17,7 +17,6 @@ import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -32,7 +31,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -121,12 +119,9 @@ export default { collapsedTooltipTitle() { return this.listTitle || this.listAssignee; }, - shouldDisplaySwimlanes() { - return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; - }, }, methods: { - ...mapActions(['updateList', 'setActiveId']), + ...mapActions(['setActiveId']), openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -160,11 +155,7 @@ export default { } }, updateListFunction() { - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); - } else { - this.list.update(); - } + this.list.update(); }, }, }; @@ -188,8 +179,9 @@ export default { 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, }" - class="board-title gl-m-0 gl-display-flex js-board-handle" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" > <gl-button v-if="list.isExpandable" @@ -202,7 +194,15 @@ export default { @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> - <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon"> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > <gl-icon name="timer" /> </span> @@ -210,6 +210,9 @@ export default { v-if="showAssigneeListDetails" :href="list.assignee.path" class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" > <img v-gl-tooltip.hover.bottom @@ -223,20 +226,28 @@ export default { </a> <div class="board-title-text" - :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" > <span v-if="list.type !== 'label'" v-gl-tooltip.hover :class="{ - 'gl-display-inline-block': list.type === 'milestone', + 'gl-display-block': !list.isExpanded || list.type === 'milestone', }" :title="listTitle" - class="board-title-main-text block-truncated" + class="board-title-main-text gl-text-truncate" > {{ list.title }} </span> - <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> + <span + v-if="list.type === 'assignee'" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + :class="{ 'gl-display-none': !list.isExpanded }" + > @{{ listAssignee }} </span> <gl-label @@ -279,7 +290,10 @@ export default { <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" - :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" > <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue new file mode 100644 index 00000000000..99347a4cd4d --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -0,0 +1,358 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlButton, + GlButtonGroup, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { n__, s__ } from '~/locale'; +import AccessorUtilities from '../../lib/utils/accessor'; +import IssueCount from './issue_count.vue'; +import eventHub from '../eventhub'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId, LIST, ListType } from '../constants'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlButtonGroup, + GlButton, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + IssueCount, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + isSwimlanesHeader: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + weightFeatureAvailable: { + default: false, + }, + scopedLabelsAvailable: { + default: false, + }, + currentUserId: { + default: null, + }, + }, + computed: { + ...mapState(['activeId']), + isLoggedIn() { + return Boolean(this.currentUserId); + }, + listType() { + return this.list.type; + }, + listAssignee() { + return this.list?.assignee?.username || ''; + }, + listTitle() { + return this.list?.label?.description || this.list.title || ''; + }, + showListHeaderButton() { + return ( + !this.disabled && + this.listType !== ListType.closed && + this.listType !== ListType.blank && + this.listType !== ListType.promotion + ); + }, + showMilestoneListDetails() { + return ( + this.list.type === ListType.milestone && + this.list.milestone && + (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + showAssigneeListDetails() { + return ( + this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + issuesCount() { + return this.list.issuesSize; + }, + issuesTooltipLabel() { + return n__(`%d issue`, `%d issues`, this.issuesCount); + }, + chevronTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, + chevronIcon() { + return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; + }, + isNewIssueShown() { + return this.listType === ListType.backlog || this.showListHeaderButton; + }, + isSettingsShown() { + return ( + this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded + ); + }, + showBoardListAndBoardInfo() { + return this.listType !== ListType.blank && this.listType !== ListType.promotion; + }, + uniqueKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `boards.${this.boardId}.${this.listType}.${this.list.id}`; + }, + collapsedTooltipTitle() { + return this.listTitle || this.listAssignee; + }, + headerStyle() { + return { borderTopColor: this.list?.label?.color }; + }, + }, + methods: { + ...mapActions(['updateList', 'setActiveId']), + openSidebarSettings() { + if (this.activeId === inactiveId) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + }, + showScopedLabels(label) { + return this.scopedLabelsAvailable && isScopedLabel(label); + }, + + showNewIssueForm() { + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + toggleExpanded() { + this.list.isExpanded = !this.list.isExpanded; + + if (!this.isLoggedIn) { + this.addToLocalStorage(); + } else { + this.updateListFunction(); + } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + this.$root.$emit('bv::hide::tooltip'); + }, + addToLocalStorage() { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + } + }, + updateListFunction() { + this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); + }, + }, +}; +</script> + +<template> + <header + :class="{ + 'has-border': list.label && list.label.color, + 'gl-h-full': !list.isExpanded, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, + }" + :style="headerStyle" + class="board-header gl-relative" + data-qa-selector="board_list_header" + data-testid="board-list-header" + > + <h3 + :class="{ + 'user-can-drag': !disabled && !list.preset, + 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, + 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, + 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, + }" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" + > + <gl-button + v-if="list.isExpandable" + v-gl-tooltip.hover + :aria-label="chevronTooltip" + :title="chevronTooltip" + :icon="chevronIcon" + class="board-title-caret no-drag gl-cursor-pointer" + variant="link" + @click="toggleExpanded" + /> + <!-- EE start --> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > + <gl-icon name="timer" /> + </span> + + <a + v-if="showAssigneeListDetails" + :href="list.assignee.path" + class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" + > + <img + v-gl-tooltip.hover.bottom + :title="listAssignee" + :alt="list.assignee.name" + :src="list.assignee.avatar" + class="avatar s20" + height="20" + width="20" + /> + </a> + <!-- EE end --> + <div + class="board-title-text" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" + > + <!-- EE start --> + <span + v-if="listType !== 'label'" + v-gl-tooltip.hover + :class="{ + 'gl-display-block': !list.isExpanded || listType === 'milestone', + }" + :title="listTitle" + class="board-title-main-text gl-text-truncate" + > + {{ list.title }} + </span> + <span + v-if="listType === 'assignee'" + v-show="list.isExpanded" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + > + @{{ listAssignee }} + </span> + <!-- EE end --> + <gl-label + v-if="listType === 'label'" + v-gl-tooltip.hover.bottom + :background-color="list.label.color" + :description="list.label.description" + :scoped="showScopedLabels(list.label)" + :size="!list.isExpanded ? 'sm' : ''" + :title="list.label.title" + /> + </div> + + <!-- EE start --> + <span + v-if="isSwimlanesHeader && !list.isExpanded" + ref="collapsedInfo" + aria-hidden="true" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" + > + <gl-icon name="information" /> + </span> + <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> + <div v-if="list.maxIssueCount !== 0"> + • + <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> + <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #maxIssueCount>{{ list.maxIssueCount }}</template> + </gl-sprintf> + </div> + <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-if="weightFeatureAvailable"> + • + <gl-sprintf :message="__('%{totalWeight} total weight')"> + <template #totalWeight>{{ list.totalWeight }}</template> + </gl-sprintf> + </div> + </gl-tooltip> + <!-- EE end --> + + <div + v-if="showBoardListAndBoardInfo" + class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" + > + <span class="gl-display-inline-flex"> + <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> + <span ref="issueCount" class="issue-count-badge-count"> + <gl-icon class="gl-mr-2" name="issues" /> + <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> + </span> + <!-- EE start --> + <template v-if="weightFeatureAvailable"> + <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> + <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> + <gl-icon class="gl-mr-2" name="weight" /> + {{ list.totalWeight }} + </span> + </template> + <!-- EE end --> + </span> + </div> + <gl-button-group + v-if="isNewIssueShown || isSettingsShown" + class="board-list-button-group pl-2" + > + <gl-button + v-if="isNewIssueShown" + v-show="list.isExpanded" + ref="newIssueBtn" + v-gl-tooltip.hover + :aria-label="__('New issue')" + :title="__('New issue')" + class="issue-count-badge-add-button no-drag" + icon="plus" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="__('List settings')" + class="no-drag js-board-settings-button" + :title="__('List settings')" + icon="settings" + @click="openSidebarSettings" + /> + <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> + </gl-button-group> + </h3> + </header> +</template> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 0a495d05122..396aedcc557 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import BoardNewIssue from './board_new_issue.vue'; +import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 0a665b82880..a9e6d768656 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,4 @@ <script> -import $ from 'jquery'; -import { mapActions, mapGetters } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; @@ -9,6 +7,8 @@ import ProjectSelect from './project_select.vue'; import boardsStore from '../stores/boards_store'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards + export default { name: 'BoardNewIssue', components: { @@ -31,23 +31,18 @@ export default { }; }, computed: { - ...mapGetters(['isSwimlanesOn']), disabled() { if (this.groupId) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; }, - shouldDisplaySwimlanes() { - return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; - }, }, mounted() { this.$refs.input.focus(); eventHub.$on('setSelectedProject', this.setSelectedProject); }, methods: { - ...mapActions(['addListIssue', 'addListIssueFailure']), submit(e) { e.preventDefault(); if (this.title.trim() === '') return Promise.resolve(); @@ -74,31 +69,14 @@ export default { eventHub.$emit(`scroll-board-list-${this.list.id}`); this.cancel(); - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.addListIssue({ list: this.list, issue, position: 0 }); - } - return this.list .newIssue(issue) .then(() => { - // Need this because our jQuery very kindly disables buttons on ALL form submissions - $(this.$refs.submitButton).enable(); - - if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) { - boardsStore.setIssueDetail(issue); - boardsStore.setListDetail(this.list); - } + boardsStore.setIssueDetail(issue); + boardsStore.setListDetail(this.list); }) .catch(() => { - // Need this because our jQuery very kindly disables buttons on ALL form submissions - $(this.$refs.submitButton).enable(); - - // Remove the issue - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.addListIssueFailure({ list: this.list, issue }); - } else { - this.list.removeIssue(issue); - } + this.list.removeIssue(issue); // Show error message this.error = true; @@ -137,7 +115,7 @@ export default { <gl-button ref="submitButton" :disabled="disabled" - class="float-left" + class="float-left js-no-auto-disable" variant="success" category="primary" type="submit" diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue new file mode 100644 index 00000000000..969c84ddb59 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue @@ -0,0 +1,129 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import eventHub from '../eventhub'; +import ProjectSelect from './project_select.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; + +export default { + name: 'BoardNewIssue', + i18n: { + submit: __('Submit issue'), + cancel: __('Cancel'), + }, + components: { + ProjectSelect, + GlButton, + }, + mixins: [glFeatureFlagMixin()], + props: { + list: { + type: Object, + required: true, + }, + }, + inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], + data() { + return { + title: '', + selectedProject: {}, + }; + }, + computed: { + disabled() { + if (this.groupId) { + return this.title === '' || !this.selectedProject.name; + } + return this.title === ''; + }, + inputFieldId() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.list.id}-title`; + }, + }, + mounted() { + this.$refs.input.focus(); + eventHub.$on('setSelectedProject', this.setSelectedProject); + }, + methods: { + ...mapActions(['addListNewIssue']), + submit(e) { + e.preventDefault(); + + const labels = this.list.label ? [this.list.label] : []; + const assignees = this.list.assignee ? [this.list.assignee] : []; + const milestone = getMilestone(this.list); + + const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; + + const { title } = this; + + eventHub.$emit(`scroll-board-list-${this.list.id}`); + + return this.addListNewIssue({ + issueInput: { + title, + labelIds: labels?.map(l => l.id), + assigneeIds: assignees?.map(a => a?.id), + milestoneId: milestone?.id, + projectPath: this.selectedProject.path, + weight: weight >= 0 ? weight : null, + }, + list: this.list, + }).then(() => { + this.reset(); + }); + }, + reset() { + this.title = ''; + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + setSelectedProject(selectedProject) { + this.selectedProject = selectedProject; + }, + }, +}; +</script> + +<template> + <div class="board-new-issue-form"> + <div class="board-card position-relative p-3 rounded"> + <form ref="submitForm" @submit="submit"> + <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> + <input + :id="inputFieldId" + ref="input" + v-model="title" + class="form-control" + type="text" + name="issue_title" + autocomplete="off" + /> + <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <div class="clearfix gl-mt-3"> + <gl-button + ref="submitButton" + :disabled="disabled" + class="float-left js-no-auto-disable" + variant="success" + category="primary" + type="submit" + > + {{ $options.i18n.submit }} + </gl-button> + <gl-button + ref="cancelButton" + class="float-right" + type="button" + variant="default" + @click="reset" + > + {{ $options.i18n.cancel }} + </gl-button> + </div> + </form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_promotion_state.js @@ -0,0 +1 @@ +export default {}; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 392e056dcbf..80070b25bd0 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -36,6 +36,9 @@ export default { computed: { ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapState(['activeId', 'sidebarType', 'boardLists']), + isWipLimitsOn() { + return this.glFeatures.wipLimits; + }, activeList() { /* Warning: Though a computed property it is not reactive because we are @@ -66,14 +69,18 @@ export default { eventHub.$off('sidebar.closeAll', this.unsetActiveId); }, methods: { - ...mapActions(['unsetActiveId']), + ...mapActions(['unsetActiveId', 'removeList']), showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, deleteBoard() { // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to delete this list?'))) { - this.activeList.destroy(); + if (window.confirm(__('Are you sure you want to remove this list?'))) { + if (this.shouldUseGraphQL) { + this.removeList(this.activeId); + } else { + this.activeList.destroy(); + } this.unsetActiveId(); } }, @@ -105,7 +112,10 @@ export default { :active-list="activeList" :board-list-type="boardListType" /> - <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + <board-settings-sidebar-wip-limit + v-if="isWipLimitsOn" + :max-issue-count="activeList.maxIssueCount" + /> <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> <gl-button variant="danger" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 271e1fc4b5f..0b079c78209 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -261,7 +261,7 @@ export default { > <gl-deprecated-dropdown-item v-show="filteredBoards.length === 0" - class="no-pointer-events text-secondary" + class="gl-pointer-events-none text-secondary" > {{ s__('IssueBoards|No matching boards found') }} </gl-deprecated-dropdown-item> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a181ea51c4a..45ce1e51489 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -3,7 +3,7 @@ import { sortBy } from 'lodash'; import { mapState } from 'vuex'; import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; @@ -89,6 +89,12 @@ export default { orderedLabels() { return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); }, + blockedLabel() { + if (this.issue.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + } + return __('Blocked issue'); + }, }, methods: { isIndexLessThanlimit(index) { @@ -133,15 +139,16 @@ export default { </script> <template> <div> - <div class="d-flex board-card-header" dir="auto"> + <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> <gl-icon v-if="issue.blocked" v-gl-tooltip name="issue-block" - :title="__('Blocked issue')" + :title="blockedLabel" class="issue-blocked-icon gl-mr-2" - :aria-label="__('Blocked issue')" + :aria-label="blockedLabel" + data-testid="issue-blocked-icon" /> <gl-icon v-if="issue.confidential" @@ -156,7 +163,7 @@ export default { }}</a> </h4> </div> - <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap"> + <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <template v-for="label in orderedLabels"> <gl-label :key="label.id" @@ -169,24 +176,26 @@ export default { /> </template> </div> - <div class="board-card-footer d-flex justify-content-between align-items-end"> + <div + class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end" + > <div - class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container" + class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > <span v-if="issue.referencePath" - class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" > <tooltip-on-truncate v-if="issueReferencePath" :title="issueReferencePath" placement="bottom" - class="board-issue-path block-truncated bold" + class="board-issue-path gl-text-truncate gl-font-weight-bold" >{{ issueReferencePath }}</tooltip-on-truncate > #{{ issue.iid }} </span> - <span class="board-info-items gl-mt-3 d-inline-block"> + <span class="board-info-items gl-mt-3 gl-display-inline-block"> <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight @@ -196,20 +205,20 @@ export default { /> </span> </div> - <div class="board-card-assignee d-flex"> + <div class="board-card-assignee gl-display-flex"> <user-avatar-link v-for="(assignee, index) in issue.assignees" v-if="shouldRenderAssignee(index)" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar || assignee.avatar_url" + :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" > <span class="js-assignee-tooltip"> - <span class="bold d-block">{{ __('Assignee') }}</span> + <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> {{ assignee.name }} <span class="text-white-50">@{{ assignee.username }}</span> </span> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index cd4512f320f..eb2db260717 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,13 +1,13 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { components: { GlButton, + GlSprintf, }, mixins: [modalMixin], props: { @@ -34,11 +34,8 @@ export default { if (this.activeTab === 'selected') { obj.title = __("You haven't selected any issues yet"); - obj.content = sprintf( - __( - 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.', - ), - { startTag: '<strong>', endTag: '</strong>' }, + obj.content = __( + 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.', ); } @@ -57,7 +54,13 @@ export default { <div class="col-12 col-md-6 order-md-first"> <div class="text-content"> <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> + <p> + <gl-sprintf :message="contents.content"> + <template #tag="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> <gl-button v-if="activeTab === 'all'" :href="newIssuePath" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index c8926c5ef2a..47eee5306da 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; import { fullLabelId } from '../boards_util'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; @@ -61,7 +62,7 @@ export default function initNewListDropdown() { const active = boardsStore.findListByLabelId(label.id); const $li = $('<li />'); const $a = $('<a />', { - class: active ? `is-active js-board-list-${active.id}` : '', + class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '', text: label.title, href: '#', }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 566c0081b9d..f90fe582566 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -44,6 +44,7 @@ export default { this.selectedProject = { id: $el.data('project-id'), name: $el.data('project-name'), + path: $el.data('project-path'), }; eventHub.$emit('setSelectedProject', this.selectedProject); }, @@ -75,11 +76,12 @@ export default { renderRow(project) { return ` <li> - <a href='#' class='dropdown-menu-link' data-project-id="${ - project.id - }" data-project-name="${project.name}" data-project-name-with-namespace="${ - project.name_with_namespace - }"> + <a href='#' class='dropdown-menu-link' + data-project-id="${project.id}" + data-project-name="${project.name}" + data-project-name-with-namespace="${project.name_with_namespace}" + data-project-path="${project.path_with_namespace}" + > ${escape(project.name_with_namespace)} </a> </li> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue new file mode 100644 index 00000000000..6935ead2706 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -0,0 +1,111 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlButton, GlDatepicker } from '@gitlab/ui'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlButton, + GlDatepicker, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }), + hasDueDate() { + return this.issue.dueDate != null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.issue.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return ''; + } + + return dateInWords(this.parsedDueDate, true); + }, + }, + methods: { + ...mapActions(['setActiveIssueDueDate']), + async openDatePicker() { + await this.$nextTick(); + this.$refs.datePicker.calendar.show(); + }, + async setDueDate(date) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null; + await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue }); + } catch (e) { + createFlash({ message: this.$options.i18n.updateDueDateError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + dueDate: __('Due date'), + removeDueDate: __('remove due date'), + updateDueDateError: __('An error occurred when updating the issue due date'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + class="board-sidebar-due-date" + :title="$options.i18n.dueDate" + :loading="loading" + @open="openDatePicker" + > + <template v-if="hasDueDate" #collapsed> + <div class="gl-display-flex gl-align-items-center"> + <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong> + <span class="gl-mx-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-400!" + data-testid="reset-button" + :disabled="loading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </template> + <template> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </board-editable-item> +</template> +<style> +/* + * This can be removed after closing: + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048 + */ +.board-sidebar-due-date .gl-datepicker, +.board-sidebar-due-date .gl-datepicker-input { + width: 100%; +} +</style> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 0f063c7582e..9d537a4ef2c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,9 +21,9 @@ export default { }, inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { - ...mapGetters({ issue: 'getActiveIssue' }), + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), selectedLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, @@ -31,17 +31,13 @@ export default { })); }, issueLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, scoped: isScopedLabel(label), })); }, - projectPath() { - const { referencePath = '' } = this.issue; - return referencePath.slice(0, referencePath.indexOf('#')); - }, }, methods: { ...mapActions(['setActiveIssueLabels']), @@ -55,7 +51,7 @@ export default { .filter(label => !payload.find(selected => selected.id === label.id)) .map(label => label.id); - const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred while updating labels.') }); @@ -68,7 +64,7 @@ export default { try { const removeLabelIds = [getIdFromGraphQLId(id)]; - const input = { removeLabelIds, projectPath: this.projectPath }; + const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred when removing the label.') }); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue new file mode 100644 index 00000000000..ed069cea630 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -0,0 +1,71 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlToggle } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + i18n: { + header: { + title: __('Notifications'), + /* Any change to subscribeDisabledDescription + must be reflected in app/helpers/notifications_helper.rb */ + subscribeDisabledDescription: __( + 'Notifications have been disabled by the project or group owner', + ), + }, + updateSubscribedErrorMessage: s__( + 'IssueBoards|An error occurred while setting notifications status.', + ), + }, + components: { + GlToggle, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + notificationText() { + return this.activeIssue.emailsDisabled + ? this.$options.i18n.header.subscribeDisabledDescription + : this.$options.i18n.header.title; + }, + }, + methods: { + ...mapActions(['setActiveIssueSubscribed']), + async handleToggleSubscription() { + this.loading = true; + + try { + await this.setActiveIssueSubscribed({ + subscribed: !this.activeIssue.subscribed, + projectPath: this.projectPathForActiveIssue, + }); + } catch (error) { + createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-testid="sidebar-notifications" + > + <span data-testid="notification-header-text"> {{ notificationText }} </span> + <gl-toggle + v-if="!activeIssue.emailsDisabled" + :value="activeIssue.subscribed" + :is-loading="loading" + data-testid="notification-subscribe-toggle" + @change="handleToggleSubscription" + /> + </div> +</template> |