diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 45b9e57f9ab..c6122fbc686 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -1,6 +1,7 @@ +import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; -import { n__ } from '~/locale'; +import { n__, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import AccessorUtilities from '../../lib/utils/accessor'; @@ -53,12 +54,19 @@ export default Vue.extend({ const { issuesSize } = this.list; return `${n__('%d issue', '%d issues', issuesSize)}`; }, + caretTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, isNewIssueShown() { return ( this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') ); }, + uniqueKey() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; + }, }, watch: { filter: { @@ -72,31 +80,34 @@ export default Vue.extend({ }, }, mounted() { - this.sortableOptions = getBoardSortableDefaultOptions({ + const instance = this; + + const sortableOptions = getBoardSortableDefaultOptions({ disabled: this.disabled, group: 'boards', draggable: '.is-draggable', handle: '.js-board-handle', - onEnd: e => { + onEnd(e) { sortableEnd(); + const sortable = this; + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); + const order = sortable.toArray(); const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10)); - this.$nextTick(() => { + instance.$nextTick(() => { boardsStore.moveList(list, order); }); } }, }); - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + Sortable.create(this.$el.parentNode, sortableOptions); }, created() { if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { - const isCollapsed = - localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false'; + const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false'; this.list.isExpanded = !isCollapsed; } @@ -105,16 +116,17 @@ export default Vue.extend({ showNewIssueForm() { this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; }, - toggleExpanded(e) { - if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { + toggleExpanded() { + if (this.list.isExpandable) { this.list.isExpanded = !this.list.isExpanded; if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem( - `boards.${this.boardId}.${this.list.type}.expanded`, - this.list.isExpanded, - ); + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + $('.tooltip').tooltip('hide'); } }, }, diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 636ca99952c..68ea28e68d9 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) { 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); const defaultSortOptions = Object.assign({}, sortableConfig, { - filter: '.board-delete, .btn', + filter: '.no-drag', delay: touchEnabled ? 100 : 0, scrollSensitivity: touchEnabled ? 60 : 100, scrollSpeed: 20, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index a9d88f19146..cd553d0c4af 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -26,6 +26,12 @@ const TYPES = { isExpandable: false, isBlank: true, }, + default: { + // includes label, assignee, and milestone lists + isPreset: false, + isExpandable: true, + isBlank: false, + }, }; class List { @@ -249,7 +255,7 @@ class List { } getTypeInfo(type) { - return TYPES[type] || {}; + return TYPES[type] || TYPES.default; } onNewIssueResponse(issue, data) { diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 5e3652db48f..343cca96851 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -92,9 +92,20 @@ width: 400px; } - &.is-expandable { - .board-header { - cursor: pointer; + .board-title-caret { + cursor: pointer; + border-radius: $border-radius-default; + padding: 4px; + + &:hover { + background-color: $gray-dark; + transition: background-color 0.1s linear; + } + } + + &:not(.is-collapsed) { + .board-title-caret { + margin: 0 $gl-padding-4 0 -10px; } } @@ -102,20 +113,51 @@ width: 50px; .board-title { - > span { - width: 100%; - margin-top: -12px; + flex-direction: column; + height: 100%; + padding: $gl-padding-8 0; + } + + .board-title-caret { + margin-top: 1px; + } + + .user-avatar-link, + .milestone-icon { + margin-top: $gl-padding-8; + transform: rotate(90deg); + } + + .board-title-text { + flex-grow: 0; + margin: $gl-padding-8 0; + + .board-title-main-text { display: block; - transform: rotate(90deg) translate(35px, 0); - overflow: initial; + } + + .board-title-sub-text { + display: none; } } - .board-title-expandable-toggle { - position: absolute; - top: 50%; - left: 50%; - margin-left: -10px; + .issue-count-badge { + border: 0; + white-space: nowrap; + } + + .board-title-text > span, + .issue-count-badge > span { + height: 16px; + + // Force the height to be equal to the parent's width while centering the contents. + // The contents *should* be about 16 px. + // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a + // rotated element has square dimensions so it won't overlap with its siblings. + margin: calc(50% - 8px) 0; + + transform: rotate(90deg); + transform-origin: center; } } } @@ -152,12 +194,14 @@ } .board-title { + align-items: center; font-size: 1em; border-bottom: 1px solid $border-color; + padding: $gl-padding-8 $gl-padding; } .board-title-text { - margin: $gl-vert-padding auto $gl-vert-padding 0; + flex-grow: 1; } .board-delete { diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index f9cfcabc015..fdb2a0a1843 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,52 +1,60 @@ .board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', ":data-id" => "list.id" } .board-inner.d-flex.flex-column.position-relative.h-100.rounded - %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" } - %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' } - %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", - ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }", - "aria-hidden": "true" } + %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }" } + %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' } + + .board-title-caret.no-drag{ "v-if": "list.isExpandable", + "aria-hidden": "true", + ":aria-label": "caretTooltip", + ":title": "caretTooltip", + "v-tooltip": "", + data: { placement: "bottom" }, + "@click": "toggleExpanded" } + %i.fa.fa-fw{ ":class": '{ "fa-caret-right": list.isExpanded, "fa-caret-down": !list.isExpanded }' } = render_if_exists "shared/boards/components/list_milestone" %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } -# haml-lint:disable AltText %img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } - %span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"", - ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } } - {{ list.title }} + .board-title-text + %span.board-title-main-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"", + ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } } + {{ list.title }} - %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", - ":title" => '(list.assignee && list.assignee.username || "")' } - @{{ list.assignee.username }} + %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"", + ":title" => '(list.assignee && list.assignee.username || "")' } + @{{ list.assignee.username }} - %span.has-tooltip{ "v-if": "list.type === \"label\"", - ":title" => '(list.label ? list.label.description : "")', - data: { container: "body", placement: "bottom" }, - class: "badge color-label title board-title-text", - ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } - {{ list.title }} + %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"", + ":title" => '(list.label ? list.label.description : "")', + data: { container: "body", placement: "bottom" }, + ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" } + {{ list.title }} - if can?(current_user, :admin_list, current_board_parent) %board-delete{ "inline-template" => true, ":list" => "list", "v-if" => "!list.preset && list.id" } - %button.board-delete.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } + %button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" } = icon("trash") - .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } } - %span.issue-count-badge-count - %icon.mr-1{ name: "issues" } - {{ list.issuesSize }} - = render_if_exists "shared/boards/components/list_weight" - %button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button", + .issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } } + %span.d-inline-flex + %span.issue-count-badge-count + %icon.mr-1{ name: "issues" } + {{ list.issuesSize }} + = render_if_exists "shared/boards/components/list_weight" + + %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button", "@click" => "showNewIssueForm", "v-if" => "isNewIssueShown", ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("New issue"), "title" => _("New issue"), data: { placement: "top", container: "body" } } - = icon("plus", class: "js-no-trigger-collapse") + = icon("plus") %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":list" => "list", diff --git a/changelogs/unreleased/mh-collapsible-boards.yml b/changelogs/unreleased/mh-collapsible-boards.yml new file mode 100644 index 00000000000..b69d6e81cc4 --- /dev/null +++ b/changelogs/unreleased/mh-collapsible-boards.yml @@ -0,0 +1,5 @@ +--- +title: Labeled issue boards can now collapse +merge_request: 29955 +author: +type: added diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 840664298d3..a04a0acd98e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1606,6 +1606,12 @@ msgstr "" msgid "Boards" msgstr "" +msgid "Boards|Collapse" +msgstr "" + +msgid "Boards|Expand" +msgstr "" + msgid "Branch %{branchName} was not found in this project's repository." msgstr "" diff --git a/spec/javascripts/boards/components/board_spec.js b/spec/javascripts/boards/components/board_spec.js index d08ee41802b..683783334c6 100644 --- a/spec/javascripts/boards/components/board_spec.js +++ b/spec/javascripts/boards/components/board_spec.js @@ -1,7 +1,6 @@ import Vue from 'vue'; -import '~/boards/services/board_service'; import Board from '~/boards/components/board'; -import '~/boards/models/list'; +import List from '~/boards/models/list'; import { mockBoardService } from '../mock_data'; describe('Board component', () => { @@ -27,7 +26,6 @@ describe('Board component', () => { disabled: false, issueLinkBase: '/', rootPath: '/', - // eslint-disable-next-line no-undef list: new List({ id: 1, position: 0, @@ -53,57 +51,62 @@ describe('Board component', () => { expect(vm.$el.classList.contains('is-expandable')).toBe(true); }); - it('board is expandable when list type is closed', done => { - vm.list.type = 'closed'; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('is-expandable')).toBe(true); - - done(); - }); + it('board is expandable when list type is closed', () => { + expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true); }); - it('board is not expandable when list type is label', done => { - vm.list.type = 'label'; - vm.list.isExpandable = false; - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('is-expandable')).toBe(false); - - done(); - }); + it('board is expandable when list type is label', () => { + expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true); }); - it('collapses when clicking header', done => { + it('board is not expandable when list type is blank', () => { + expect(new List({ id: 1, list_type: 'blank' }).isExpandable).toBe(false); + }); + + it('does not collapse when clicking header', done => { + vm.list.isExpanded = true; vm.$el.querySelector('.board-header').click(); Vue.nextTick(() => { - expect(vm.$el.classList.contains('is-collapsed')).toBe(true); + expect(vm.$el.classList.contains('is-collapsed')).toBe(false); done(); }); }); - it('created sets isExpanded to true from localStorage', done => { - vm.$el.querySelector('.board-header').click(); + it('collapses when clicking the collapse icon', done => { + vm.list.isExpanded = true; - return Vue.nextTick() + Vue.nextTick() .then(() => { - expect(vm.$el.classList.contains('is-collapsed')).toBe(true); - - // call created manually - vm.$options.created[0].call(vm); - - return Vue.nextTick(); + vm.$el.querySelector('.board-title-caret').click(); }) .then(() => { expect(vm.$el.classList.contains('is-collapsed')).toBe(true); - done(); }) .catch(done.fail); }); + it('expands when clicking the expand icon', done => { + vm.list.isExpanded = false; + + Vue.nextTick() + .then(() => { + vm.$el.querySelector('.board-title-caret').click(); + }) + .then(() => { + expect(vm.$el.classList.contains('is-collapsed')).toBe(false); + done(); + }) + .catch(done.fail); + }); + + it('is expanded when created', () => { + expect(vm.list.isExpanded).toBe(true); + expect(vm.$el.classList.contains('is-collapsed')).toBe(false); + }); + it('does render add issue button', () => { expect(vm.$el.querySelector('.issue-count-badge-add-button')).not.toBeNull(); });