From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <d.csapak@proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by lists.proxmox.com (Postfix) with ESMTPS id 375E79BC40
 for <pve-devel@lists.proxmox.com>; Tue, 21 Nov 2023 13:48:11 +0100 (CET)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 152A0948E
 for <pve-devel@lists.proxmox.com>; Tue, 21 Nov 2023 13:47:41 +0100 (CET)
Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com
 [94.136.29.106])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS
 for <pve-devel@lists.proxmox.com>; Tue, 21 Nov 2023 13:47:37 +0100 (CET)
Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1])
 by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 072F64105B
 for <pve-devel@lists.proxmox.com>; Tue, 21 Nov 2023 13:47:37 +0100 (CET)
From: Dominik Csapak <d.csapak@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Tue, 21 Nov 2023 13:47:32 +0100
Message-Id: <20231121124732.2803365-4-d.csapak@proxmox.com>
X-Mailer: git-send-email 2.30.2
In-Reply-To: <20231121124732.2803365-1-d.csapak@proxmox.com>
References: <20231121124732.2803365-1-d.csapak@proxmox.com>
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.017 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DMARC_MISSING             0.1 Missing DMARC policy
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
 T_SCC_BODY_TEXT_LINE    -0.01 -
Subject: [pve-devel] [PATCH manager v2 1/1] ui: implement 'Tag View' for the
 resource tree
X-BeenThere: pve-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox VE development discussion <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Tue, 21 Nov 2023 12:48:11 -0000

and keep the functionality in ResourceTree as generic as possible.

We achieve this by having an 'itemMap' function that can split one item
from the store into multiple to add to the tree.

for the updates, we have to have an 'idMapFn' (to get the original id
back)

we also have to modify how the move checks work a bit, since we only
want to move the items when the tags changed only in the tagview case

in the ResourceGrid we have to get the id a bit differently since we now
have 'virtual' ids for the entries tag contain the tag (which can't be
found in the resource store)

since we also don't want tooltips for the already expanded tag groups,
we have to add the special condition that the element directly above
the tag should not have the 'full' class (like it is in tag group case)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 www/manager6/Makefile             |  1 +
 www/manager6/Workspace.js         |  3 +-
 www/manager6/form/ViewSelector.js | 32 ++++++++++++++++++
 www/manager6/grid/ResourceGrid.js |  2 +-
 www/manager6/panel/TagConfig.js   |  8 +++++
 www/manager6/tree/ResourceTree.js | 54 +++++++++++++++++++++++++++----
 6 files changed, 92 insertions(+), 8 deletions(-)
 create mode 100644 www/manager6/panel/TagConfig.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index ee09f0b8..5efd0726 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -105,6 +105,7 @@ JSSRC= 							\
 	panel/GuestSummary.js				\
 	panel/TemplateStatusView.js			\
 	panel/MultiDiskEdit.js				\
+	panel/TagConfig.js				\
 	tree/ResourceTree.js				\
 	tree/SnapshotTree.js				\
 	tree/ResourceMapTree.js				\
diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 89ca47b7..424023b6 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -247,6 +247,7 @@ Ext.define('PVE.StdWorkspace', {
 			    storage: 'PVE.storage.Browser',
 			    sdn: 'PVE.sdn.Browser',
 			    pool: 'pvePoolConfig',
+			    tag: 'pveTagConfig',
 			};
 			PVE.curSelectedNode = treeNode;
 			me.setContent({
@@ -530,7 +531,7 @@ Ext.define('PVE.StdWorkspace', {
 	let tagSelectors = [];
 	['circle', 'dense'].forEach((style) => {
 	    ['dark', 'light'].forEach((variant) => {
-		tagSelectors.push(`.proxmox-tags-${style} .proxmox-tag-${variant}`);
+		tagSelectors.push(`.proxmox-tags-${style} :not(.proxmox-tags-full) > .proxmox-tag-${variant}`);
 	    });
 	});
 
diff --git a/www/manager6/form/ViewSelector.js b/www/manager6/form/ViewSelector.js
index e25547c4..647399ef 100644
--- a/www/manager6/form/ViewSelector.js
+++ b/www/manager6/form/ViewSelector.js
@@ -32,6 +32,38 @@ Ext.define('PVE.form.ViewSelector', {
 		// Pool View only lists VMs and Containers
 		filterfn: ({ data }) => data.type === 'qemu' || data.type === 'lxc' || data.type === 'pool',
 	    },
+	    tags: {
+		text: gettext('Tag View'),
+		groups: ['tag'],
+		filterfn: ({ data }) => data.type === 'qemu' || data.type === 'lxc',
+		groupRenderer: function(info) {
+		    let tag = PVE.Utils.renderTags(info.tag, PVE.UIOptions.tagOverrides);
+		    return `<span class="proxmox-tags-full">${tag}</span>`;
+		},
+		idMapFn: function(id) {
+		    let [realId, _tag] = id.split('-');
+		    return realId;
+		},
+		itemMap: function(item) {
+		    let tags = (item.data.tags ?? '').split(/[;, ]/);
+		    if (tags.length === 1 && tags[0] === '') {
+			return item;
+		    }
+		    let items = [];
+		    for (const tag of tags) {
+			let id = `${item.data.id}-${tag}`;
+			let info = Ext.apply({ leaf: true }, item.data);
+			info.tag = tag;
+			info.realId = info.id;
+			info.id = id;
+			items.push(Ext.create('Ext.data.TreeModel', info));
+		    }
+		    return items;
+		},
+		attrMoveChecks: {
+		    tag: (newitem, olditem) => newitem.data.tags !== olditem.data.tags,
+		},
+	    },
 	};
 	let groupdef = Object.entries(default_views).map(([name, config]) => [name, config.text]);
 
diff --git a/www/manager6/grid/ResourceGrid.js b/www/manager6/grid/ResourceGrid.js
index 9376bcc2..b212e9e9 100644
--- a/www/manager6/grid/ResourceGrid.js
+++ b/www/manager6/grid/ResourceGrid.js
@@ -44,7 +44,7 @@ Ext.define('PVE.grid.ResourceGrid', {
 		    return;
 		}
 		for (let child of node.childNodes) {
-		    let orgNode = rstore.data.get(child.data.id);
+		    let orgNode = rstore.data.get(child.data.realId ?? child.data.id);
 		    if (orgNode) {
 			if ((!filterfn || filterfn(child)) && (!textfilter || textfilterMatch(child))) {
 			    nodeidx[child.data.id] = orgNode;
diff --git a/www/manager6/panel/TagConfig.js b/www/manager6/panel/TagConfig.js
new file mode 100644
index 00000000..e26205bd
--- /dev/null
+++ b/www/manager6/panel/TagConfig.js
@@ -0,0 +1,8 @@
+// Config panel for the tag groups
+// for now it contains only the autogenerated 'search' panel
+Ext.define('PVE.panel.TagConfig', {
+    extend: 'PVE.panel.Config',
+    alias: 'widget.pveTagConfig',
+
+    onlineHelp: 'gui_tags',
+});
diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js
index acfa545a..67ce74dd 100644
--- a/www/manager6/tree/ResourceTree.js
+++ b/www/manager6/tree/ResourceTree.js
@@ -37,6 +37,9 @@ Ext.define('PVE.tree.ResourceTree', {
 	    template: {
 		iconCls: 'fa fa-file-o',
 	    },
+	    tag: {
+		iconCls: 'fa fa-tag',
+	    },
 	},
     },
 
@@ -135,7 +138,7 @@ Ext.define('PVE.tree.ResourceTree', {
     },
 
     getToolTip: function(info) {
-	if (info.type === 'pool' || info.groupbyid !== undefined) {
+	if (info.type === 'pool' || info.tag !== undefined || info.groupbyid !== undefined) {
 	    return undefined;
 	}
 
@@ -166,12 +169,15 @@ Ext.define('PVE.tree.ResourceTree', {
 	me.setText(info);
 
 	if (info.groupbyid) {
-	    info.text = info.groupbyid;
-	    if (info.type === 'type') {
+	    if (me.viewFilter.groupRenderer) {
+		info.text = me.viewFilter.groupRenderer(info);
+	    } else if (info.type === 'type') {
 		let defaults = PVE.tree.ResourceTree.typeDefaults[info.groupbyid];
 		if (defaults && defaults.text) {
 		    info.text = defaults.text;
 		}
+	    } else {
+		info.text = info.groupbyid;
 	    }
 	}
 	let child = Ext.create('PVETree', info);
@@ -283,6 +289,9 @@ Ext.define('PVE.tree.ResourceTree', {
 
 	    let groups = me.viewFilter.groups || [];
 	    // explicitly check for node/template, as those are not always grouping attributes
+	    let attrMoveChecks = me.viewFilter.attrMoveChecks ?? {};
+	    let idMapFn = me.viewFilter.idMapFn ?? Ext.identityFn;
+
 	    // also check for name for when the tree is sorted by name
 	    let moveCheckAttrs = groups.concat(['node', 'template', 'name']);
 	    let filterfn = me.viewFilter.filterfn;
@@ -292,13 +301,20 @@ Ext.define('PVE.tree.ResourceTree', {
 	    // remove vanished or moved items and update changed items in-place
 	    for (const [key, olditem] of Object.entries(index)) {
 		// getById() use find(), which is slow (ExtJS4 DP5)
-		let item = rstore.data.get(olditem.data.id);
+		let oldid = olditem.data.id;
+		let id = idMapFn(olditem.data.id);
+		let item = rstore.data.get(id);
 
 		let changed = sorting_changed, moved = sorting_changed;
 		if (item) {
 		    // test if any grouping attributes changed, catches migrated tree-nodes in server view too
 		    for (const attr of moveCheckAttrs) {
-			if (item.data[attr] !== olditem.data[attr]) {
+			if (attrMoveChecks[attr]) {
+			    if (attrMoveChecks[attr](olditem, item)) {
+				moved = true;
+				break;
+			    }
+			} else if (item.data[attr] !== olditem.data[attr]) {
 			    moved = true;
 			    break;
 			}
@@ -318,6 +334,9 @@ Ext.define('PVE.tree.ResourceTree', {
 		    olditem.beginEdit();
 		    let info = olditem.data;
 		    Ext.apply(info, item.data);
+		    if (info.id !== oldid) {
+			info.id = oldid;
+		    }
 		    me.setIconCls(info);
 		    me.setText(info);
 		    olditem.commit();
@@ -334,10 +353,15 @@ Ext.define('PVE.tree.ResourceTree', {
 		    // store events are suspended, so remove the item manually
 		    store.remove(olditem);
 		    parentNode.removeChild(olditem, true);
+		    if (parentNode.childNodes.length < 1 && parentNode.parentNode) {
+			let grandParent = parentNode.parentNode;
+			grandParent.removeChild(parentNode, true);
+		    }
 		}
 	    }
 
-	    rstore.each(function(item) { // add new items
+	    let items = rstore.getData().items.flatMap(me.viewFilter.itemMap ?? Ext.identityFn);
+	    items.forEach(function(item) { // add new items
 		let olditem = index[item.data.id];
 		if (olditem) {
 		    return;
@@ -499,6 +523,24 @@ Ext.define('PVE.tree.ResourceTree', {
 
 	rstore.on("load", updateTree);
 	rstore.startUpdate();
+
+	if (me.viewFilter.groupRenderer) {
+	    me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => {
+		me.store.getRootNode().cascadeBy({
+		    before: function(node) {
+			if (node.data.groupbyid) {
+			    node.beginEdit();
+			    let info = node.data;
+			    me.setIconCls(info);
+			    me.setText(info);
+			    info.text = me.viewFilter.groupRenderer(info);
+			    node.commit();
+			}
+			return true;
+		    },
+		});
+	    });
+	}
     },
 
 });
-- 
2.30.2