Merge pull request #3638 from Icinga/feature/js-collapsible-containers
Persistent Collapsible Containers
This commit is contained in:
commit
6cd94080bd
|
@ -833,6 +833,18 @@
|
|||
"css": "th-list",
|
||||
"code": 61449,
|
||||
"src": "mfglabs"
|
||||
},
|
||||
{
|
||||
"uid": "63b3012c8cbe3654ba5bea598235aa3a",
|
||||
"css": "angle-double-up",
|
||||
"code": 61698,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "dfec4ffa849d8594c2e4b86f6320b8a6",
|
||||
"css": "angle-double-down",
|
||||
"code": 61699,
|
||||
"src": "fontawesome"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -135,5 +135,7 @@
|
|||
.icon-th-list:before { content: '\f009'; } /* '' */
|
||||
.icon-th-thumb-empty:before { content: '\f00b'; } /* '' */
|
||||
.icon-github-circled:before { content: '\f09b'; } /* '' */
|
||||
.icon-angle-double-up:before { content: '\f102'; } /* '' */
|
||||
.icon-angle-double-down:before { content: '\f103'; } /* '' */
|
||||
.icon-history:before { content: '\f1da'; } /* '' */
|
||||
.icon-binoculars:before { content: '\f1e5'; } /* '' */
|
File diff suppressed because one or more lines are too long
|
@ -135,5 +135,7 @@
|
|||
.icon-th-list { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-th-thumb-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-github-circled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-angle-double-up { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-angle-double-down { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-history { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
|
@ -146,5 +146,7 @@
|
|||
.icon-th-list { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-th-thumb-empty { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-github-circled { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-angle-double-up { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-angle-double-down { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-history { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.icon-binoculars { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
|
@ -1,11 +1,11 @@
|
|||
@font-face {
|
||||
font-family: 'ifont';
|
||||
src: url('../font/ifont.eot?15561604');
|
||||
src: url('../font/ifont.eot?15561604#iefix') format('embedded-opentype'),
|
||||
url('../font/ifont.woff2?15561604') format('woff2'),
|
||||
url('../font/ifont.woff?15561604') format('woff'),
|
||||
url('../font/ifont.ttf?15561604') format('truetype'),
|
||||
url('../font/ifont.svg?15561604#ifont') format('svg');
|
||||
src: url('../font/ifont.eot?42867337');
|
||||
src: url('../font/ifont.eot?42867337#iefix') format('embedded-opentype'),
|
||||
url('../font/ifont.woff2?42867337') format('woff2'),
|
||||
url('../font/ifont.woff?42867337') format('woff'),
|
||||
url('../font/ifont.ttf?42867337') format('truetype'),
|
||||
url('../font/ifont.svg?42867337#ifont') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
|||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: 'ifont';
|
||||
src: url('../font/ifont.svg?15561604#ifont') format('svg');
|
||||
src: url('../font/ifont.svg?42867337#ifont') format('svg');
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -191,5 +191,7 @@
|
|||
.icon-th-list:before { content: '\f009'; } /* '' */
|
||||
.icon-th-thumb-empty:before { content: '\f00b'; } /* '' */
|
||||
.icon-github-circled:before { content: '\f09b'; } /* '' */
|
||||
.icon-angle-double-up:before { content: '\f102'; } /* '' */
|
||||
.icon-angle-double-down:before { content: '\f103'; } /* '' */
|
||||
.icon-history:before { content: '\f1da'; } /* '' */
|
||||
.icon-binoculars:before { content: '\f1e5'; } /* '' */
|
|
@ -229,11 +229,11 @@ body {
|
|||
}
|
||||
@font-face {
|
||||
font-family: 'ifont';
|
||||
src: url('./font/ifont.eot?97051739');
|
||||
src: url('./font/ifont.eot?97051739#iefix') format('embedded-opentype'),
|
||||
url('./font/ifont.woff?97051739') format('woff'),
|
||||
url('./font/ifont.ttf?97051739') format('truetype'),
|
||||
url('./font/ifont.svg?97051739#ifont') format('svg');
|
||||
src: url('./font/ifont.eot?11177247');
|
||||
src: url('./font/ifont.eot?11177247#iefix') format('embedded-opentype'),
|
||||
url('./font/ifont.woff?11177247') format('woff'),
|
||||
url('./font/ifont.ttf?11177247') format('truetype'),
|
||||
url('./font/ifont.svg?11177247#ifont') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -502,6 +502,8 @@ body {
|
|||
<div class="the-icons span3" title="Code: 0xf09b"><i class="demo-icon icon-github-circled"></i> <span class="i-name">icon-github-circled</span><span class="i-code">0xf09b</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="the-icons span3" title="Code: 0xf102"><i class="demo-icon icon-angle-double-up"></i> <span class="i-name">icon-angle-double-up</span><span class="i-code">0xf102</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf103"><i class="demo-icon icon-angle-double-down"></i> <span class="i-name">icon-angle-double-down</span><span class="i-code">0xf103</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf1da"><i class="demo-icon icon-history"></i> <span class="i-name">icon-history</span><span class="i-code">0xf1da</span></div>
|
||||
<div class="the-icons span3" title="Code: 0xf1e5"><i class="demo-icon icon-binoculars"></i> <span class="i-name">icon-binoculars</span><span class="i-code">0xf1e5</span></div>
|
||||
</div>
|
||||
|
|
Binary file not shown.
|
@ -278,6 +278,10 @@
|
|||
|
||||
<glyph glyph-name="github-circled" unicode="" d="M429 779q116 0 215-58t156-156 57-215q0-140-82-252t-211-155q-15-3-22 4t-7 17q0 1 0 43t0 75q0 54-29 79 32 3 57 10t53 22 45 37 30 58 11 84q0 67-44 115 21 51-4 114-16 5-46-6t-51-25l-21-13q-52 15-107 15t-108-15q-8 6-23 15t-47 22-47 7q-25-63-5-114-44-48-44-115 0-47 12-83t29-59 45-37 52-22 57-10q-21-20-27-58-12-5-25-8t-32-3-36 12-31 35q-11 18-27 29t-28 14l-11 1q-12 0-16-2t-3-7 5-8 7-6l4-3q12-6 24-21t18-29l6-13q7-21 24-34t37-17 39-3 31 1l13 3q0-22 0-50t1-30q0-10-8-17t-22-4q-129 43-211 155t-82 252q0 117 58 215t155 156 216 58z m-267-616q2 4-3 7-6 1-8-1-1-4 4-7 5-3 7 1z m18-19q4 3-1 9-6 5-9 2-4-3 1-9 5-6 9-2z m16-25q6 4 0 11-4 7-9 3-5-3 0-10t9-4z m24-23q4 4-2 10-7 7-11 2-5-5 2-11 6-6 11-1z m32-14q1 6-8 9-8 2-10-4t7-9q8-3 11 4z m35-3q0 7-10 6-9 0-9-6 0-7 10-6 9 0 9 6z m32 5q-1 7-10 5-9-1-8-8t10-4 8 7z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="angle-double-up" unicode="" d="M600 118q0-7-6-13l-28-28q-5-5-12-5t-13 5l-220 219-219-219q-5-5-13-5t-12 5l-28 28q-6 6-6 13t6 13l260 260q5 5 12 5t13-5l260-260q6-6 6-13z m0 214q0-7-6-13l-28-28q-5-5-12-5t-13 5l-220 220-219-220q-5-5-13-5t-12 5l-28 28q-6 6-6 13t6 13l260 260q5 6 12 6t13-6l260-260q6-6 6-13z" horiz-adv-x="642.9" />
|
||||
|
||||
<glyph glyph-name="angle-double-down" unicode="" d="M600 368q0-7-6-13l-260-260q-5-6-13-6t-12 6l-260 260q-6 6-6 13t6 13l28 28q5 5 12 5t13-5l219-220 220 220q5 5 13 5t12-5l28-28q6-6 6-13z m0 214q0-7-6-13l-260-260q-5-5-13-5t-12 5l-260 260q-6 6-6 13t6 13l28 28q5 6 12 6t13-6l219-219 220 219q5 6 13 6t12-6l28-28q6-6 6-13z" horiz-adv-x="642.9" />
|
||||
|
||||
<glyph glyph-name="history" unicode="" d="M857 350q0-87-34-166t-91-137-137-92-166-34q-96 0-183 41t-147 114q-4 6-4 13t5 11l76 77q6 5 14 5 9-1 13-7 41-53 100-82t126-29q58 0 110 23t92 61 61 91 22 111-22 111-61 91-92 61-110 23q-55 0-105-20t-90-57l77-77q17-16 8-38-10-23-33-23h-250q-15 0-25 11t-11 25v250q0 24 22 33 22 10 39-8l72-72q60 57 137 88t159 31q87 0 166-34t137-92 91-137 34-166z m-357 161v-250q0-8-5-13t-13-5h-178q-8 0-13 5t-5 13v35q0 8 5 13t13 5h125v197q0 8 5 13t12 5h36q8 0 13-5t5-13z" horiz-adv-x="857.1" />
|
||||
|
||||
<glyph glyph-name="binoculars" unicode="" d="M393 671v-428q0-15-11-25t-25-11v-321q0-15-10-25t-26-11h-285q-15 0-25 11t-11 25v285l139 488q4 12 17 12h237z m178 0v-392h-142v392h142z m429-500v-285q0-15-11-25t-25-11h-285q-15 0-25 11t-11 25v321q-15 0-25 11t-11 25v428h237q13 0 17-12z m-589 661v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z m375 0v-125h-197v125q0 8 5 13t13 5h161q8 0 13-5t5-13z" horiz-adv-x="1000" />
|
||||
|
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -76,6 +76,12 @@ $innerLayoutScript = $this->layout()->innerLayout . '.phtml';
|
|||
}
|
||||
}());
|
||||
</script>
|
||||
<div id="collapsible-control-ghost" class="collapsible-control">
|
||||
<button>
|
||||
<?= $this->icon('angle-double-down', $this->translate('Expand'), ['class' => 'expand-icon']) ?>
|
||||
<?= $this->icon('angle-double-up', $this->translate('Collapse'), ['class' => 'collapse-icon']) ?>
|
||||
</button>
|
||||
</div>
|
||||
<!--[if lt IE 10]>
|
||||
<iframe id="fileupload-frame-target" name="fileupload-frame-target"></iframe>
|
||||
<![endif]-->
|
||||
|
|
|
@ -13,6 +13,7 @@ class JavaScript
|
|||
'js/helpers.js',
|
||||
'js/icinga.js',
|
||||
'js/icinga/logger.js',
|
||||
'js/icinga/storage.js',
|
||||
'js/icinga/utils.js',
|
||||
'js/icinga/ui.js',
|
||||
'js/icinga/timer.js',
|
||||
|
@ -24,6 +25,7 @@ class JavaScript
|
|||
'js/icinga/timezone.js',
|
||||
'js/icinga/behavior/application-state.js',
|
||||
'js/icinga/behavior/autofocus.js',
|
||||
'js/icinga/behavior/collapsible.js',
|
||||
'js/icinga/behavior/detach.js',
|
||||
'js/icinga/behavior/tooltip.js',
|
||||
'js/icinga/behavior/sparkline.js',
|
||||
|
|
|
@ -161,13 +161,6 @@ a:hover > .icon-cancel {
|
|||
background-color: @tr-hover-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
caption {
|
||||
border-top: 1px solid @gray-light;
|
||||
caption-side: bottom;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.name-value-table {
|
||||
|
@ -233,3 +226,85 @@ a:hover > .icon-cancel {
|
|||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible Control
|
||||
#collapsible-control-ghost {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-control {
|
||||
position: relative;
|
||||
|
||||
button {
|
||||
.rounded-corners(50%);
|
||||
|
||||
background: @gray-lighter;
|
||||
color: @gray;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
border: none;
|
||||
padding: 0;
|
||||
bottom: -1em;
|
||||
right: .25em;
|
||||
-webkit-box-shadow: 0 0 1/3em rgba(0,0,0,.3);
|
||||
-moz-box-shadow: 0 0 1/3em rgba(0,0,0,.3);
|
||||
box-shadow: 0 0 1/3em rgba(0,0,0,.3);
|
||||
|
||||
&:hover:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -1/6em;
|
||||
right: -1/6em;
|
||||
bottom: -1/6em;
|
||||
left: -1/6em;
|
||||
background: fade(@text-color, 10);
|
||||
.rounded-corners(50%);
|
||||
}
|
||||
}
|
||||
|
||||
button i:before {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible.can-collapse:not(.collapsed) + .collapsible-control button {
|
||||
> i.expand-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> i.collapse-icon {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible.collapsed + .collapsible-control button {
|
||||
> i.expand-icon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
> i.collapse-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsibles
|
||||
|
||||
.collapsible.collapsed {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 2em;
|
||||
background: linear-gradient(rgba(255,255,255,0), white);
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
|
||||
|
||||
;(function(Icinga, $) {
|
||||
|
||||
'use strict';
|
||||
|
||||
Icinga.Behaviors = Icinga.Behaviors || {};
|
||||
|
||||
/**
|
||||
* Behavior for collapsible containers.
|
||||
*
|
||||
* @param icinga Icinga The current Icinga Object
|
||||
*/
|
||||
var Collapsible = function(icinga) {
|
||||
Icinga.EventListener.call(this, icinga);
|
||||
|
||||
this.on('layout-change', this.onLayoutChange, this);
|
||||
this.on('rendered', '.container', this.onRendered, this);
|
||||
this.on('click', '.collapsible + .collapsible-control', this.onControlClicked, this);
|
||||
|
||||
this.icinga = icinga;
|
||||
this.defaultVisibleRows = 2;
|
||||
this.defaultVisibleHeight = 36;
|
||||
|
||||
this.state = new Icinga.Storage.StorageAwareMap.withStorage(
|
||||
Icinga.Storage.BehaviorStorage('collapsible'),
|
||||
'expanded'
|
||||
)
|
||||
.on('add', this.onExpand, this)
|
||||
.on('delete', this.onCollapse, this);
|
||||
};
|
||||
|
||||
Collapsible.prototype = new Icinga.EventListener();
|
||||
|
||||
/**
|
||||
* Initializes all collapsibles. Triggered on rendering of a container.
|
||||
*
|
||||
* @param event Event The `onRender` event triggered by the rendered container
|
||||
*/
|
||||
Collapsible.prototype.onRendered = function(event) {
|
||||
var _this = event.data.self;
|
||||
|
||||
$('.collapsible:not(.can-collapse)', event.currentTarget).each(function() {
|
||||
var $collapsible = $(this);
|
||||
|
||||
// Assumes that any newly rendered elements are expanded
|
||||
if (_this.canCollapse($collapsible)) {
|
||||
$collapsible.after($('#collapsible-control-ghost').clone().removeAttr('id'));
|
||||
$collapsible.addClass('can-collapse');
|
||||
|
||||
if (! _this.state.has(_this.icinga.utils.getCSSPath($collapsible))) {
|
||||
_this.collapse($collapsible);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates all collapsibles.
|
||||
*
|
||||
* @param event Event The `layout-change` event triggered by window resizing or column changes
|
||||
*/
|
||||
Collapsible.prototype.onLayoutChange = function(event) {
|
||||
var _this = event.data.self;
|
||||
|
||||
$('.collapsible').each(function() {
|
||||
var $collapsible = $(this);
|
||||
var collapsiblePath = _this.icinga.utils.getCSSPath($collapsible);
|
||||
|
||||
if ($collapsible.is('.can-collapse')) {
|
||||
if (! _this.canCollapse($collapsible)) {
|
||||
$collapsible.next('.collapsible-control').remove();
|
||||
$collapsible.removeClass('can-collapse');
|
||||
_this.expand($collapsible);
|
||||
}
|
||||
} else if (_this.canCollapse($collapsible)) {
|
||||
// It's expanded but shouldn't
|
||||
$collapsible.after($('#collapsible-control-ghost').clone().removeAttr('id'));
|
||||
$collapsible.addClass('can-collapse');
|
||||
|
||||
if (! _this.state.has(collapsiblePath)) {
|
||||
_this.collapse($collapsible);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A collapsible got expanded in another window, try to apply this here as well
|
||||
*
|
||||
* @param {string} collapsiblePath
|
||||
*/
|
||||
Collapsible.prototype.onExpand = function(collapsiblePath) {
|
||||
var $collapsible = $(collapsiblePath);
|
||||
|
||||
if ($collapsible.length && $collapsible.is('.can-collapse')) {
|
||||
this.expand($collapsible);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A collapsible got collapsed in another window, try to apply this here as well
|
||||
*
|
||||
* @param {string} collapsiblePath
|
||||
*/
|
||||
Collapsible.prototype.onCollapse = function(collapsiblePath) {
|
||||
var $collapsible = $(collapsiblePath);
|
||||
|
||||
if ($collapsible.length && this.canCollapse($collapsible)) {
|
||||
this.collapse($collapsible);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Event handler for toggling collapsibles. Switches the collapsed state of the respective container.
|
||||
*
|
||||
* @param event Event The `onClick` event triggered by the clicked collapsible-control element
|
||||
*/
|
||||
Collapsible.prototype.onControlClicked = function(event) {
|
||||
var _this = event.data.self;
|
||||
var $target = $(event.currentTarget);
|
||||
var $collapsible = $target.prev('.collapsible');
|
||||
|
||||
if (! $collapsible.length) {
|
||||
_this.icinga.logger.error('[Collapsible] Collapsible control has no associated .collapsible: ', $target);
|
||||
} else {
|
||||
var collapsiblePath = _this.icinga.utils.getCSSPath($collapsible);
|
||||
if (_this.state.has(collapsiblePath)) {
|
||||
_this.state.delete(collapsiblePath);
|
||||
_this.collapse($collapsible);
|
||||
} else {
|
||||
_this.state.set(collapsiblePath);
|
||||
_this.expand($collapsible);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return an appropriate row element selector
|
||||
*
|
||||
* @param $collapsible jQuery The given collapsible container element
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
Collapsible.prototype.getRowSelector = function($collapsible) {
|
||||
if ($collapsible.is('table')) {
|
||||
return '> tbody > tr';
|
||||
} else if ($collapsible.is('ul, ol')) {
|
||||
return '> li';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the given collapsible needs to collapse
|
||||
*
|
||||
* @param $collapsible jQuery The given collapsible container element
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
Collapsible.prototype.canCollapse = function($collapsible) {
|
||||
var rowSelector = this.getRowSelector($collapsible);
|
||||
if (!! rowSelector) {
|
||||
return $(rowSelector, $collapsible).length > ($collapsible.data('visibleRows') || this.defaultVisibleRows);
|
||||
} else {
|
||||
var actualHeight = $collapsible[0].scrollHeight;
|
||||
var maxHeight = $collapsible.data('visibleHeight') || this.defaultVisibleHeight;
|
||||
|
||||
if (actualHeight <= maxHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Although the height seems larger than what it should be, make sure it's not just a small fraction
|
||||
// i.e. more than 12 pixel and at least 10% difference
|
||||
return actualHeight - maxHeight > 12 && actualHeight / maxHeight >= 1.1;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse the given collapsible
|
||||
*
|
||||
* @param $collapsible jQuery The given collapsible container element
|
||||
*/
|
||||
Collapsible.prototype.collapse = function($collapsible) {
|
||||
$collapsible.addClass('collapsed');
|
||||
|
||||
var rowSelector = this.getRowSelector($collapsible);
|
||||
if (!! rowSelector) {
|
||||
var $rows = $(rowSelector, $collapsible).slice(0, $collapsible.data('visibleRows') || this.defaultVisibleRows);
|
||||
|
||||
var totalHeight = $rows.offset().top - $collapsible.offset().top;
|
||||
$rows.outerHeight(function(_, height) {
|
||||
totalHeight += height;
|
||||
});
|
||||
|
||||
$collapsible.css({display: 'block', height: totalHeight});
|
||||
} else {
|
||||
$collapsible.css({display: 'block', height: $collapsible.data('visibleHeight') || this.defaultVisibleHeight});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand the given collapsible
|
||||
*
|
||||
* @param $collapsible jQuery The given collapsible container element
|
||||
*/
|
||||
Collapsible.prototype.expand = function($collapsible) {
|
||||
$collapsible.removeClass('collapsed');
|
||||
$collapsible.css({display: '', height: ''});
|
||||
};
|
||||
|
||||
Icinga.Behaviors.Collapsible = Collapsible;
|
||||
|
||||
})(Icinga, jQuery);
|
|
@ -0,0 +1,526 @@
|
|||
/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
|
||||
|
||||
;(function(Icinga) {
|
||||
|
||||
'use strict';
|
||||
|
||||
const KEY_TTL = 7776000000; // 90 days (90×24×60×60×1000)
|
||||
|
||||
/**
|
||||
* Icinga.Storage
|
||||
*
|
||||
* localStorage access
|
||||
*
|
||||
* @param {string} prefix
|
||||
*/
|
||||
Icinga.Storage = function(prefix) {
|
||||
|
||||
/**
|
||||
* Prefix to use for keys
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
this.prefix = prefix;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callbacks for storage events on particular keys
|
||||
*
|
||||
* @type {{function}}
|
||||
*/
|
||||
Icinga.Storage.subscribers = {};
|
||||
|
||||
/**
|
||||
* Pass storage events to subscribers
|
||||
*
|
||||
* @param {StorageEvent} event
|
||||
*/
|
||||
window.addEventListener('storage', function(event) {
|
||||
var url = icinga.utils.parseUrl(event.url);
|
||||
if (! url.path.substring(0, icinga.config.baseUrl.length) === icinga.config.baseUrl) {
|
||||
// A localStorage is shared between all paths on the same origin.
|
||||
// So we need to make sure it's us who made a change.
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Icinga.Storage.subscribers[event.key] !== 'undefined') {
|
||||
var newValue = null,
|
||||
oldValue = null;
|
||||
if (!! event.newValue) {
|
||||
try {
|
||||
newValue = JSON.parse(event.newValue);
|
||||
} catch(error) {
|
||||
icinga.logger.error('[Storage] Failed to parse new value (\`' + event.newValue
|
||||
+ '\`) for key "' + event.key + '". Error was: ' + error);
|
||||
event.storageArea.removeItem(event.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!! event.oldValue) {
|
||||
try {
|
||||
oldValue = JSON.parse(event.oldValue);
|
||||
} catch(error) {
|
||||
icinga.logger.warn('[Storage] Failed to parse old value (\`' + event.oldValue
|
||||
+ '\`) of key "' + event.key + '". Error was: ' + error);
|
||||
oldValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
Icinga.Storage.subscribers[event.key].forEach(function (subscriber) {
|
||||
subscriber[0].call(subscriber[1], newValue, oldValue, event);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new storage with `behavior.<name>` as prefix
|
||||
*
|
||||
* @param {string} name
|
||||
*
|
||||
* @returns {Icinga.Storage}
|
||||
*/
|
||||
Icinga.Storage.BehaviorStorage = function(name) {
|
||||
return new Icinga.Storage('behavior.' + name);
|
||||
};
|
||||
|
||||
Icinga.Storage.prototype = {
|
||||
|
||||
/**
|
||||
* Prefix the given key
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
prefixKey: function(key) {
|
||||
var prefix = 'icinga.';
|
||||
if (typeof this.prefix !== 'undefined') {
|
||||
prefix = prefix + this.prefix + '.';
|
||||
}
|
||||
|
||||
return prefix + key;
|
||||
},
|
||||
|
||||
/**
|
||||
* Store the given key-value pair
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
set: function(key, value) {
|
||||
window.localStorage.setItem(this.prefixKey(key), JSON.stringify(value));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get value for the given key
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {*}
|
||||
*/
|
||||
get: function(key) {
|
||||
key = this.prefixKey(key);
|
||||
var value = window.localStorage.getItem(key);
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch(error) {
|
||||
icinga.logger.error('[Storage] Failed to parse value (\`' + value
|
||||
+ '\`) of key "' + key + '". Error was: ' + error);
|
||||
window.localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove given key from storage
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
remove: function(key) {
|
||||
window.localStorage.removeItem(this.prefixKey(key));
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe with a callback for events on a particular key
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {function} callback
|
||||
* @param {object} context
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
onChange: function(key, callback, context) {
|
||||
var prefixedKey = this.prefixKey(key);
|
||||
|
||||
if (typeof Icinga.Storage.subscribers[prefixedKey] === 'undefined') {
|
||||
Icinga.Storage.subscribers[prefixedKey] = [];
|
||||
}
|
||||
|
||||
Icinga.Storage.subscribers[prefixedKey].push([callback, context]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Icinga.Storage.StorageAwareMap
|
||||
*
|
||||
* @param {object} items
|
||||
* @constructor
|
||||
*/
|
||||
Icinga.Storage.StorageAwareMap = function(items) {
|
||||
|
||||
/**
|
||||
* Storage object
|
||||
*
|
||||
* @type {Icinga.Storage}
|
||||
*/
|
||||
this.storage = undefined;
|
||||
|
||||
/**
|
||||
* Storage key
|
||||
*
|
||||
* @type {string}
|
||||
*/
|
||||
this.key = undefined;
|
||||
|
||||
/**
|
||||
* Event listeners for our internal events
|
||||
*
|
||||
* @type {{}}
|
||||
*/
|
||||
this.eventListeners = {
|
||||
'add': [],
|
||||
'delete': []
|
||||
};
|
||||
|
||||
/**
|
||||
* The internal (real) map
|
||||
*
|
||||
* @type {Map<*>}
|
||||
*/
|
||||
this.data = new Map();
|
||||
|
||||
// items is not passed directly because IE11 doesn't support constructor arguments
|
||||
if (typeof items !== 'undefined' && !! items) {
|
||||
Object.keys(items).forEach(function(key) {
|
||||
this.data.set(key, items[key]);
|
||||
}, this);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new StorageAwareMap for the given storage and key
|
||||
*
|
||||
* @param {Icinga.Storage} storage
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {Icinga.Storage.StorageAwareMap}
|
||||
*/
|
||||
Icinga.Storage.StorageAwareMap.withStorage = function(storage, key) {
|
||||
var items = storage.get(key);
|
||||
if (typeof items !== 'undefined' && !! items) {
|
||||
Object.keys(items).forEach(function(key) {
|
||||
var value = items[key];
|
||||
|
||||
if (typeof value !== 'object' || typeof value['lastAccess'] === 'undefined') {
|
||||
items[key] = {'value': value, 'lastAccess': Date.now()};
|
||||
} else if (Date.now() - value['lastAccess'] > KEY_TTL) {
|
||||
delete items[key];
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
|
||||
if (!! items && items.length) {
|
||||
storage.set(key, items);
|
||||
} else if(items !== null) {
|
||||
storage.remove(key);
|
||||
}
|
||||
|
||||
return (new Icinga.Storage.StorageAwareMap(items).setStorage(storage, key));
|
||||
};
|
||||
|
||||
Icinga.Storage.StorageAwareMap.prototype = {
|
||||
|
||||
/**
|
||||
* Bind this map to the given storage and key
|
||||
*
|
||||
* @param {Icinga.Storage} storage
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {this}
|
||||
*/
|
||||
setStorage: function(storage, key) {
|
||||
this.storage = storage;
|
||||
this.key = key;
|
||||
|
||||
storage.onChange(key, this.onChange, this);
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a boolean indicating this map got a storage
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasStorage: function() {
|
||||
return typeof this.storage !== 'undefined' && typeof this.key !== 'undefined';
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the storage
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
updateStorage: function() {
|
||||
if (! this.hasStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.size > 0) {
|
||||
this.storage.set(this.key, this.toObject());
|
||||
} else {
|
||||
this.storage.remove(this.key);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the map
|
||||
*
|
||||
* @param {object} newValue
|
||||
*/
|
||||
onChange: function(newValue) {
|
||||
// Check for deletions first. Uses keys() to iterate over a copy
|
||||
this.keys().forEach(function (key) {
|
||||
if (newValue === null || typeof newValue[key] === 'undefined') {
|
||||
var value = this.data.get(key)['value'];
|
||||
this.data.delete(key);
|
||||
this.trigger('delete', key, value);
|
||||
}
|
||||
}, this);
|
||||
|
||||
if (newValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Now check for new entries
|
||||
Object.keys(newValue).forEach(function(key) {
|
||||
var known = this.data.has(key);
|
||||
// Always override any known value as we want to keep track of all `lastAccess` changes
|
||||
this.data.set(key, newValue[key]);
|
||||
|
||||
if (! known) {
|
||||
this.trigger('add', key, newValue[key]['value']);
|
||||
}
|
||||
}, this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an event handler to handle storage updates
|
||||
*
|
||||
* Available events are: add, delete. The callback receives the
|
||||
* key and its value as first and second argument, respectively.
|
||||
*
|
||||
* @param {string} event
|
||||
* @param {function} callback
|
||||
* @param {object} thisArg
|
||||
*
|
||||
* @returns {this}
|
||||
*/
|
||||
on: function(event, callback, thisArg) {
|
||||
if (typeof this.eventListeners[event] === 'undefined') {
|
||||
throw new Error('Invalid event "' + event + '"');
|
||||
}
|
||||
|
||||
this.eventListeners[event].push([callback, thisArg]);
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger all event handlers for the given event
|
||||
*
|
||||
* @param {string} event
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
*/
|
||||
trigger: function(event, key, value) {
|
||||
this.eventListeners[event].forEach(function (handler) {
|
||||
var thisArg = handler[1];
|
||||
if (typeof thisArg === 'undefined') {
|
||||
thisArg = this;
|
||||
}
|
||||
|
||||
handler[0].call(thisArg, key, value);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the number of key/value pairs in the map
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
get size() {
|
||||
return this.data.size;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the value for the key in the map
|
||||
*
|
||||
* @param {string} key
|
||||
* @param {*} value Default null
|
||||
*
|
||||
* @returns {this}
|
||||
*/
|
||||
set: function(key, value) {
|
||||
if (typeof value === 'undefined') {
|
||||
value = null;
|
||||
}
|
||||
|
||||
this.data.set(key, {'value': value, 'lastAccess': Date.now()});
|
||||
|
||||
this.updateStorage();
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all key/value pairs from the map
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear: function() {
|
||||
this.data.clear();
|
||||
this.updateStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the given key from the map
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
delete: function(key) {
|
||||
var retVal = this.data.delete(key);
|
||||
|
||||
this.updateStorage();
|
||||
return retVal;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a list of [key, value] pairs for every item in the map
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
entries: function() {
|
||||
var list = [];
|
||||
|
||||
if (this.size > 0) {
|
||||
this.data.forEach(function (value, key) {
|
||||
list.push([key, value['value']]);
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute a provided function once for each item in the map, in insertion order
|
||||
*
|
||||
* @param {function} callback
|
||||
* @param {object} thisArg
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
forEach: function(callback, thisArg) {
|
||||
if (typeof thisArg === 'undefined') {
|
||||
thisArg = this;
|
||||
}
|
||||
|
||||
this.data.forEach(function(value, key) {
|
||||
callback.call(thisArg, value['value'], key);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the value associated to the key, or undefined if there is none
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {*}
|
||||
*/
|
||||
get: function(key) {
|
||||
var value = this.data.get(key)['value'];
|
||||
this.set(key, value); // Update `lastAccess`
|
||||
|
||||
return value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a boolean asserting whether a value has been associated to the key in the map
|
||||
*
|
||||
* @param {string} key
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has: function(key) {
|
||||
return this.data.has(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return an array of keys in the map
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
keys: function() {
|
||||
var list = [];
|
||||
|
||||
if (this.size > 0) {
|
||||
// .forEach() is used because IE11 doesn't support .keys()
|
||||
this.data.forEach(function(_, key) {
|
||||
list.push(key);
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return an array of values in the map
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
values: function() {
|
||||
var list = [];
|
||||
|
||||
if (this.size > 0) {
|
||||
// .forEach() is used because IE11 doesn't support .values()
|
||||
this.data.forEach(function(value) {
|
||||
list.push(value['value']);
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return this map as simple object
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
toObject: function() {
|
||||
var obj = {};
|
||||
|
||||
if (this.size > 0) {
|
||||
this.data.forEach(function (value, key) {
|
||||
obj[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
};
|
||||
|
||||
}(Icinga));
|
|
@ -268,6 +268,9 @@
|
|||
this.currentLayout = matched[1];
|
||||
if (this.currentLayout === 'poor' || this.currentLayout === 'minimal') {
|
||||
this.layout1col();
|
||||
} else {
|
||||
// layout1col() also triggers this, that's why an else is required
|
||||
$('#layout').trigger('layout-change');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
@ -293,6 +296,7 @@
|
|||
this.icinga.logger.debug('Switching to single col');
|
||||
$('#layout').removeClass('twocols');
|
||||
this.closeContainer($('#col2'));
|
||||
$('#layout').trigger('layout-change');
|
||||
// one-column layouts never have any selection active
|
||||
$('#col1').removeData('icinga-actiontable-former-href');
|
||||
this.icinga.behaviors.actiontable.clearAll();
|
||||
|
@ -315,6 +319,7 @@
|
|||
this.icinga.logger.debug('Switching to double col');
|
||||
$('#layout').addClass('twocols');
|
||||
this.fixControls();
|
||||
$('#layout').trigger('layout-change');
|
||||
},
|
||||
|
||||
getAvailableColumnSpace: function () {
|
||||
|
|
Loading…
Reference in New Issue