Allow collapsing all issue boards

All issue boards can now be collapsed via a button, re-ordered by
dragging the header, and the vertical collapsed header style was
reworked.
This commit is contained in:
Martin Hanzel 2019-06-28 21:20:05 +00:00 committed by Mike Greiling
parent 388a496443
commit 5c07812835
8 changed files with 171 additions and 87 deletions

View File

@ -1,6 +1,7 @@
import $ from 'jquery';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import Vue from 'vue'; import Vue from 'vue';
import { n__ } from '~/locale'; import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip'; import Tooltip from '~/vue_shared/directives/tooltip';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
@ -53,12 +54,19 @@ export default Vue.extend({
const { issuesSize } = this.list; const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`; return `${n__('%d issue', '%d issues', issuesSize)}`;
}, },
caretTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
isNewIssueShown() { isNewIssueShown() {
return ( return (
this.list.type === 'backlog' || this.list.type === 'backlog' ||
(!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank') (!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: { watch: {
filter: { filter: {
@ -72,31 +80,34 @@ export default Vue.extend({
}, },
}, },
mounted() { mounted() {
this.sortableOptions = getBoardSortableDefaultOptions({ const instance = this;
const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled, disabled: this.disabled,
group: 'boards', group: 'boards',
draggable: '.is-draggable', draggable: '.is-draggable',
handle: '.js-board-handle', handle: '.js-board-handle',
onEnd: e => { onEnd(e) {
sortableEnd(); sortableEnd();
const sortable = this;
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { 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)); const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
this.$nextTick(() => { instance.$nextTick(() => {
boardsStore.moveList(list, order); boardsStore.moveList(list, order);
}); });
} }
}, },
}); });
this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); Sortable.create(this.$el.parentNode, sortableOptions);
}, },
created() { created() {
if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) { if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
const isCollapsed = const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
this.list.isExpanded = !isCollapsed; this.list.isExpanded = !isCollapsed;
} }
@ -105,16 +116,17 @@ export default Vue.extend({
showNewIssueForm() { showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
}, },
toggleExpanded(e) { toggleExpanded() {
if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) { if (this.list.isExpandable) {
this.list.isExpanded = !this.list.isExpanded; this.list.isExpanded = !this.list.isExpanded;
if (AccessorUtilities.isLocalStorageAccessSafe()) { if (AccessorUtilities.isLocalStorageAccessSafe()) {
localStorage.setItem( localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
`boards.${this.boardId}.${this.list.type}.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');
} }
}, },
}, },

View File

@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) {
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch); 'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
const defaultSortOptions = Object.assign({}, sortableConfig, { const defaultSortOptions = Object.assign({}, sortableConfig, {
filter: '.board-delete, .btn', filter: '.no-drag',
delay: touchEnabled ? 100 : 0, delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100, scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20, scrollSpeed: 20,

View File

@ -26,6 +26,12 @@ const TYPES = {
isExpandable: false, isExpandable: false,
isBlank: true, isBlank: true,
}, },
default: {
// includes label, assignee, and milestone lists
isPreset: false,
isExpandable: true,
isBlank: false,
},
}; };
class List { class List {
@ -249,7 +255,7 @@ class List {
} }
getTypeInfo(type) { getTypeInfo(type) {
return TYPES[type] || {}; return TYPES[type] || TYPES.default;
} }
onNewIssueResponse(issue, data) { onNewIssueResponse(issue, data) {

View File

@ -92,9 +92,20 @@
width: 400px; width: 400px;
} }
&.is-expandable { .board-title-caret {
.board-header {
cursor: pointer; 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; width: 50px;
.board-title { .board-title {
> span { flex-direction: column;
width: 100%; height: 100%;
margin-top: -12px; 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; display: block;
transform: rotate(90deg) translate(35px, 0); }
overflow: initial;
.board-title-sub-text {
display: none;
} }
} }
.board-title-expandable-toggle { .issue-count-badge {
position: absolute; border: 0;
top: 50%; white-space: nowrap;
left: 50%; }
margin-left: -10px;
.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 { .board-title {
align-items: center;
font-size: 1em; font-size: 1em;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
padding: $gl-padding-8 $gl-padding;
} }
.board-title-text { .board-title-text {
margin: $gl-vert-padding auto $gl-vert-padding 0; flex-grow: 1;
} }
.board-delete { .board-delete {

View File

@ -1,18 +1,25 @@
.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" }', .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" } ":data-id" => "list.id" }
.board-inner.d-flex.flex-column.position-relative.h-100.rounded .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)" } %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.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 }' } %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !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 }", .board-title-caret.no-drag{ "v-if": "list.isExpandable",
"aria-hidden": "true" } "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" = render_if_exists "shared/boards/components/list_milestone"
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" } %a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText -# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" } %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\"", .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" } } ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
{{ list.title }} {{ list.title }}
@ -20,10 +27,9 @@
":title" => '(list.assignee && list.assignee.username || "")' } ":title" => '(list.assignee && list.assignee.username || "")' }
@{{ list.assignee.username }} @{{ list.assignee.username }}
%span.has-tooltip{ "v-if": "list.type === \"label\"", %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"",
":title" => '(list.label ? list.label.description : "")', ":title" => '(list.label ? list.label.description : "")',
data: { container: "body", placement: "bottom" }, 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\") }" } ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" }
{{ list.title }} {{ list.title }}
@ -31,22 +37,24 @@
%board-delete{ "inline-template" => true, %board-delete{ "inline-template" => true,
":list" => "list", ":list" => "list",
"v-if" => "!list.preset && list.id" } "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") = 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" } }
.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 %span.issue-count-badge-count
%icon.mr-1{ name: "issues" } %icon.mr-1{ name: "issues" }
{{ list.issuesSize }} {{ list.issuesSize }}
= render_if_exists "shared/boards/components/list_weight" = 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", %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button",
"@click" => "showNewIssueForm", "@click" => "showNewIssueForm",
"v-if" => "isNewIssueShown", "v-if" => "isNewIssueShown",
":class": "{ 'd-none': !list.isExpanded }", ":class": "{ 'd-none': !list.isExpanded }",
"aria-label" => _("New issue"), "aria-label" => _("New issue"),
"title" => _("New issue"), "title" => _("New issue"),
data: { placement: "top", container: "body" } } data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse") = icon("plus")
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', %board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list", ":list" => "list",

View File

@ -0,0 +1,5 @@
---
title: Labeled issue boards can now collapse
merge_request: 29955
author:
type: added

View File

@ -1606,6 +1606,12 @@ msgstr ""
msgid "Boards" msgid "Boards"
msgstr "" msgstr ""
msgid "Boards|Collapse"
msgstr ""
msgid "Boards|Expand"
msgstr ""
msgid "Branch %{branchName} was not found in this project's repository." msgid "Branch %{branchName} was not found in this project's repository."
msgstr "" msgstr ""

View File

@ -1,7 +1,6 @@
import Vue from 'vue'; import Vue from 'vue';
import '~/boards/services/board_service';
import Board from '~/boards/components/board'; import Board from '~/boards/components/board';
import '~/boards/models/list'; import List from '~/boards/models/list';
import { mockBoardService } from '../mock_data'; import { mockBoardService } from '../mock_data';
describe('Board component', () => { describe('Board component', () => {
@ -27,7 +26,6 @@ describe('Board component', () => {
disabled: false, disabled: false,
issueLinkBase: '/', issueLinkBase: '/',
rootPath: '/', rootPath: '/',
// eslint-disable-next-line no-undef
list: new List({ list: new List({
id: 1, id: 1,
position: 0, position: 0,
@ -53,57 +51,62 @@ describe('Board component', () => {
expect(vm.$el.classList.contains('is-expandable')).toBe(true); expect(vm.$el.classList.contains('is-expandable')).toBe(true);
}); });
it('board is expandable when list type is closed', done => { it('board is expandable when list type is closed', () => {
vm.list.type = 'closed'; expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
Vue.nextTick(() => {
expect(vm.$el.classList.contains('is-expandable')).toBe(true);
done();
});
}); });
it('board is not expandable when list type is label', done => { it('board is expandable when list type is label', () => {
vm.list.type = 'label'; expect(new List({ id: 1, list_type: 'closed' }).isExpandable).toBe(true);
vm.list.isExpandable = false;
Vue.nextTick(() => {
expect(vm.$el.classList.contains('is-expandable')).toBe(false);
done();
});
}); });
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(); vm.$el.querySelector('.board-header').click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true); expect(vm.$el.classList.contains('is-collapsed')).toBe(false);
done(); done();
}); });
}); });
it('created sets isExpanded to true from localStorage', done => { it('collapses when clicking the collapse icon', done => {
vm.$el.querySelector('.board-header').click(); vm.list.isExpanded = true;
return Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true); vm.$el.querySelector('.board-title-caret').click();
// call created manually
vm.$options.created[0].call(vm);
return Vue.nextTick();
}) })
.then(() => { .then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true); expect(vm.$el.classList.contains('is-collapsed')).toBe(true);
done(); done();
}) })
.catch(done.fail); .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', () => { it('does render add issue button', () => {
expect(vm.$el.querySelector('.issue-count-badge-add-button')).not.toBeNull(); expect(vm.$el.querySelector('.issue-count-badge-add-button')).not.toBeNull();
}); });