Commit 5c078128 authored by Martin Hanzel's avatar Martin Hanzel Committed by Mike Greiling

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.
parent 388a4964
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');
}
},
},
......
......@@ -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,
......
......@@ -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) {
......
......@@ -92,9 +92,20 @@
width: 400px;
}
&.is-expandable {
.board-header {
.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 {
......
.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\"",
.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 }}
......@@ -20,10 +27,9 @@
":title" => '(list.assignee && 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 : "")',
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 }}
......@@ -31,22 +37,24 @@
%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" } }
.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.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",
"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",
......
---
title: Labeled issue boards can now collapse
merge_request: 29955
author:
type: added
......@@ -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 ""
......
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('board is not expandable when list type is blank', () => {
expect(new List({ id: 1, list_type: 'blank' }).isExpandable).toBe(false);
});
it('collapses when clicking header', done => {
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(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true);
done();
})
.catch(done.fail);
});
// call created manually
vm.$options.created[0].call(vm);
it('expands when clicking the expand icon', done => {
vm.list.isExpanded = false;
return Vue.nextTick();
Vue.nextTick()
.then(() => {
vm.$el.querySelector('.board-title-caret').click();
})
.then(() => {
expect(vm.$el.classList.contains('is-collapsed')).toBe(true);
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();
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment