Skip to content

Commit a9ffcd2

Browse files
committed
Add feature to reorder via drag & drop
1 parent 350c297 commit a9ffcd2

File tree

8 files changed

+259
-0
lines changed

8 files changed

+259
-0
lines changed

library/Kubernetes/Common/BaseItemList.php

+16
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ abstract class BaseItemList extends BaseHtmlElement
2626
*/
2727
protected bool $actionList = true;
2828

29+
protected bool $draggable = false;
30+
2931
protected iterable $query;
3032

3133
protected array $baseAttributes = [
@@ -62,6 +64,20 @@ public function setActionList(bool $actionList): static
6264
return $this;
6365
}
6466

67+
/**
68+
* Enable or disable the ability to drag and drop the list items
69+
*
70+
* @param bool $draggable
71+
*
72+
* @return $this
73+
*/
74+
public function setDraggable(bool $draggable): static
75+
{
76+
$this->draggable = $draggable;
77+
78+
return $this;
79+
}
80+
6581
/**
6682
* Initialize the item list
6783
* If you want to adjust the item list after construction, override this method.

library/Kubernetes/Common/BaseListItem.php

+15
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
namespace Icinga\Module\Kubernetes\Common;
66

7+
use Icinga\Module\Kubernetes\Web\Factory;
8+
use Icinga\Module\Kubernetes\Web\MoveFavoriteForm;
79
use ipl\Html\BaseHtmlElement;
810
use ipl\Html\Html;
911
use ipl\Html\HtmlElement;
12+
use Ramsey\Uuid\Uuid;
1013

1114
/**
1215
* Base class for list items
@@ -49,6 +52,18 @@ protected function init(): void
4952

5053
protected function assemble(): void
5154
{
55+
if (isset($this->item->favorite->priority)) {
56+
$this->add(
57+
(new MoveFavoriteForm())
58+
->setAction(
59+
Links::moveFavorite(Factory::canonicalizeKind($this->item->getTableAlias()))->getAbsoluteUrl()
60+
)
61+
->populate([
62+
'uuid' => Uuid::fromBytes($this->item->uuid)->toString(),
63+
'priority' => $this->item->favorite->priority,
64+
]),
65+
);
66+
}
5267
$this->add([
5368
$this->createVisual(),
5469
$this->createMain()

library/Kubernetes/Common/Links.php

+6
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use Icinga\Module\Kubernetes\Model\Service;
2424
use Icinga\Module\Kubernetes\Model\SidecarContainer;
2525
use Icinga\Module\Kubernetes\Model\StatefulSet;
26+
use Icinga\Module\Kubernetes\Web\Factory;
2627
use ipl\Web\Url;
2728
use Ramsey\Uuid\Uuid;
2829

@@ -139,4 +140,9 @@ public static function toggleFavorite(string $uuid, $kind): Url
139140
['uuid' => (string) Uuid::fromBytes($uuid), 'kind' => $kind]
140141
);
141142
}
143+
144+
public static function moveFavorite(string $kind): Url
145+
{
146+
return Url::fromPath('kubernetes/' . Factory::pluralizeKind($kind) . '/move-favorite');
147+
}
142148
}

library/Kubernetes/Web/Factory.php

+10
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,19 @@ public static function canonicalizeKind(string $kind): string
4040
if ($kind === 'pvc') {
4141
return 'persistentvolumeclaim';
4242
}
43+
4344
return strtolower(str_replace(['_', '-'], '', $kind));
4445
}
4546

47+
public static function pluralizeKind(string $kind): string
48+
{
49+
if ($kind === 'ingress') {
50+
return $kind . 'es';
51+
}
52+
53+
return $kind . 's';
54+
}
55+
4656
public static function createIcon(string $kind): ?ValidHtml
4757
{
4858
$kind = self::canonicalizeKind($kind);

library/Kubernetes/Web/ListController.php

+14
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public function indexAction(): void
123123
(new $contentClass($favoriteResources, ['data-list-group' => 'fav', 'favorite-list' => '']))
124124
->addAttributes(['class' => 'collapsible'])
125125
->setViewMode($viewModeSwitcher->getViewMode())
126+
->setDraggable(true)
126127
);
127128
$this->addContent(Html::hr());
128129
}
@@ -137,6 +138,19 @@ public function indexAction(): void
137138
}
138139
}
139140

141+
public function moveFavoriteAction(): void
142+
{
143+
$this->assertHttpMethod('POST');
144+
145+
(new MoveFavoriteForm(Database::connection()))
146+
->on(MoveFavoriteForm::ON_SUCCESS, function () {
147+
// Suppress handling XHR response and disable view rendering,
148+
// so we can use the form in the list without the page reloading.
149+
$this->getResponse()->setHeader('X-Icinga-Container', 'ignore', true);
150+
$this->_helper->viewRenderer->setNoRender();
151+
})->handleRequest($this->getServerRequest());
152+
}
153+
140154
abstract protected function getTitle(): string;
141155

142156
abstract protected function getSortColumns(): array;
+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/* Icinga for Kubernetes Web | (c) 2025 Icinga GmbH | AGPLv3 */
4+
5+
namespace Icinga\Module\Kubernetes\Web;
6+
7+
use Icinga\Exception\Http\HttpNotFoundException;
8+
use Icinga\Module\Kubernetes\Common\Auth;
9+
use Icinga\Module\Kubernetes\Model\Favorite;
10+
use ipl\Html\Form;
11+
use ipl\Sql\Connection;
12+
use ipl\Sql\Expression;
13+
use ipl\Sql\Select;
14+
use ipl\Stdlib\Filter;
15+
use ipl\Web\Common\CsrfCounterMeasure;
16+
use LogicException;
17+
use Ramsey\Uuid\Uuid;
18+
19+
class MoveFavoriteForm extends Form
20+
{
21+
use CsrfCounterMeasure;
22+
23+
protected $defaultAttributes = ['hidden' => true];
24+
25+
protected $method = 'POST';
26+
27+
/** @var Connection */
28+
protected $db;
29+
30+
/** @var string */
31+
protected $kind;
32+
33+
/**
34+
* Create a new MoveFavoriteForm
35+
*
36+
* @param ?Connection $db
37+
*/
38+
public function __construct(Connection $db = null)
39+
{
40+
$this->db = $db;
41+
}
42+
43+
/**
44+
* Get the kind
45+
*
46+
* @return string
47+
*/
48+
public function getKind(): string
49+
{
50+
if ($this->kind === null) {
51+
throw new LogicException('The form must be successfully submitted first');
52+
}
53+
54+
return $this->kind;
55+
}
56+
57+
protected function assemble(): void
58+
{
59+
$this->addElement('hidden', 'uuid', ['required' => true]);
60+
$this->addElement('hidden', 'priority', ['required' => true]);
61+
}
62+
63+
protected function onSuccess(): void
64+
{
65+
$favoriteUuid = Uuid::fromString($this->getValue('uuid'))->getBytes();
66+
$newPriority = $this->getValue('priority');
67+
68+
/** @var ?Favorite $favorite */
69+
$favorite = Favorite::on($this->db)
70+
->columns(['kind', 'priority'])
71+
->filter(Filter::all(
72+
Filter::equal('resource_uuid', $favoriteUuid),
73+
Filter::equal('username', Auth::getInstance()->getUser()->getUsername())
74+
))
75+
->first();
76+
if ($favorite === null) {
77+
throw new HttpNotFoundException('Favorite not found');
78+
}
79+
80+
$transactionStarted = ! $this->db->inTransaction();
81+
if ($transactionStarted) {
82+
$this->db->beginTransaction();
83+
}
84+
85+
$this->kind = $favorite->kind;
86+
87+
// Free up the current priority used by the favorite in question
88+
$this->db->update('favorite', ['priority' => null], ['resource_uuid = ?' => $favoriteUuid]);
89+
90+
// Update the priorities of the favorites that are affected by the move
91+
if ($newPriority < $favorite->priority) {
92+
$affectedFavorites = $this->db->select(
93+
(new Select())
94+
->columns('resource_uuid')
95+
->from('favorite')
96+
->where([
97+
'kind = ?' => $favorite->kind,
98+
'priority >= ?' => $newPriority,
99+
'priority < ?' => $favorite->priority
100+
])
101+
->orderBy('priority', SORT_DESC)
102+
);
103+
foreach ($affectedFavorites as $affectedFavorite) {
104+
$this->db->update(
105+
'favorite',
106+
['priority' => new Expression('priority + 1')],
107+
['resource_uuid = ?' => $affectedFavorite->resource_uuid]
108+
);
109+
}
110+
} elseif ($newPriority > $favorite->priority) {
111+
$affectedFavorites = $this->db->select(
112+
(new Select())
113+
->columns('resource_uuid')
114+
->from('favorite')
115+
->where([
116+
'kind = ?' => $favorite->kind,
117+
'priority > ?' => $favorite->priority,
118+
'priority <= ?' => $newPriority
119+
])
120+
->orderBy('priority ASC')
121+
);
122+
foreach ($affectedFavorites as $affectedFavorite) {
123+
$this->db->update(
124+
'favorite',
125+
['priority' => new Expression('priority - 1')],
126+
['resource_uuid = ?' => $affectedFavorite->resource_uuid]
127+
);
128+
}
129+
}
130+
131+
// Now insert the favorite at the new priority
132+
$this->db->update('favorite', ['priority' => $newPriority], ['resource_uuid = ?' => $favoriteUuid]);
133+
134+
if ($transactionStarted) {
135+
$this->db->commitTransaction();
136+
}
137+
}
138+
}

public/css/action-list.less

+6
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,11 @@
1010
cursor: pointer;
1111
}
1212

13+
&[favorite-list] {
14+
[data-action-item]:hover {
15+
cursor: grab;
16+
}
17+
}
18+
1319
margin: 2em 0;
1420
}

public/js/action-list.js

+54
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44

55
"use strict";
66

7+
try {
8+
var Sortable = require('icinga/icinga-php-library/vendor/Sortable');
9+
} catch (e) {
10+
console.warn('Unable to provide Drag&Drop in the favorite lists. Libraries not available:', e);
11+
return;
12+
}
13+
714
Icinga.Behaviors = Icinga.Behaviors || {};
815

916
class ActionList extends Icinga.EventListener {
@@ -17,6 +24,9 @@
1724
this.on('rendered', '#main .container.module-kubernetes:has(.action-list-kubernetes)', this.onRendered, this);
1825
this.on('keydown', '#body', this.onKeyDown, this);
1926

27+
this.on('rendered', '#main .container.module-kubernetes:has([favorite-list])', this.onRenderedReorder, this);
28+
this.on('end', '[favorite-list]', this.onDropReorder, this)
29+
2030
this.lastActivatedItemUrl = null;
2131
this.lastTimeoutId = null;
2232
this.activeRequests = {};
@@ -587,6 +597,50 @@
587597
// The slash is used to avoid false positives (e.g. icingadb/hostgroup and icingadb/host)
588598
return detailUrl.startsWith(itemUrl + '/');
589599
}
600+
601+
onRenderedReorder(event) {
602+
if (event.target !== event.currentTarget) {
603+
return; // Nested containers are not of interest
604+
}
605+
606+
const favoriteList = event.target.querySelector('[favorite-list]');
607+
if (! favoriteList) {
608+
return;
609+
}
610+
611+
Sortable.create(favoriteList, {
612+
scroll: true,
613+
direction: 'vertical',
614+
draggable: '.list-item'
615+
});
616+
}
617+
618+
onDropReorder(event) {
619+
event = event.originalEvent;
620+
if (event.to === event.from && event.newIndex === event.oldIndex) {
621+
// The user dropped the rotation at its previous position
622+
return;
623+
}
624+
625+
const nextRow = event.item.nextSibling;
626+
627+
let newPriority;
628+
if (event.oldIndex > event.newIndex) {
629+
// The rotation was moved up
630+
newPriority = Number(nextRow.querySelector(':scope > form').priority.value);
631+
} else {
632+
// The rotation was moved down
633+
if (nextRow !== null && nextRow.matches('.list-item')) {
634+
newPriority = Number(nextRow.querySelector(':scope > form').priority.value) + 1;
635+
} else {
636+
newPriority = '0';
637+
}
638+
}
639+
640+
const form = event.item.querySelector(':scope > form');
641+
form.priority.value = newPriority;
642+
form.requestSubmit();
643+
}
590644
}
591645

592646
Icinga.Behaviors.ActionList = ActionList;

0 commit comments

Comments
 (0)