From 194640f55aa6ee64c9cb3a20968ce9a16e7ae283 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 26 Sep 2024 20:30:51 +1000
Subject: [PATCH] [PUI] Plugin panel context (#8190)

* Add server-side context for panel plugin rendering

* Add "context" to PluginContext type

* Pass server context through to client-side rendering

* Bump API version
---
 .../InvenTree/InvenTree/api_version.py        |  5 ++++-
 .../InvenTree/plugin/base/ui/mixins.py        |  3 +++
 .../InvenTree/plugin/base/ui/serializers.py   |  5 +++++
 .../integration/user_interface_sample.py      | 13 +++++++++++-
 .../samples/static/plugin/sample_panel.js     | 21 +++++++++++++------
 .../src/components/plugins/PluginPanel.tsx    |  5 ++++-
 src/frontend/src/hooks/UsePluginPanels.tsx    |  7 ++++++-
 7 files changed, 49 insertions(+), 10 deletions(-)

diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 43f12f7737..e247bbfcb8 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,16 @@
 """InvenTree API version information."""
 
 # InvenTree API version
-INVENTREE_API_VERSION = 259
+INVENTREE_API_VERSION = 260
 
 """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
 
 
 INVENTREE_API_TEXT = """
 
+v260 - 2024-09-26 : https://github.com/inventree/InvenTree/pull/8190
+    - Adds facility for server-side context data to be passed to client-side plugins
+
 v259 - 2024-09-20 : https://github.com/inventree/InvenTree/pull/8137
     - Implements new API endpoint for enabling custom UI features via plugins
 
diff --git a/src/backend/InvenTree/plugin/base/ui/mixins.py b/src/backend/InvenTree/plugin/base/ui/mixins.py
index b8e5a0fecb..fb5ec8eac0 100644
--- a/src/backend/InvenTree/plugin/base/ui/mixins.py
+++ b/src/backend/InvenTree/plugin/base/ui/mixins.py
@@ -19,6 +19,7 @@ class CustomPanel(TypedDict):
         label: The label of the panel (required, human readable).
         icon: The icon of the panel (optional, must be a valid icon identifier).
         content: The content of the panel (optional, raw HTML).
+        context: Optional context data (dict / JSON) which will be passed to the front-end rendering function
         source: The source of the panel (optional, path to a JavaScript file).
     """
 
@@ -26,6 +27,7 @@ class CustomPanel(TypedDict):
     label: str
     icon: str
     content: str
+    context: dict
     source: str
 
 
@@ -87,6 +89,7 @@ class UserInterfaceMixin:
             'label': 'Panel Title',  # The title of the panel (required, human readable)
             'icon': 'icon-name',  # Icon name (optional, must be a valid icon identifier)
             'content': '<p>Panel content</p>',  # HTML content to be rendered in the panel (optional)
+            'context': {'key': 'value'},  # Context data to be passed to the front-end rendering function (optional)
             'source': 'static/plugin/panel.js',  # Path to a JavaScript file to be loaded (optional)
         }
 
diff --git a/src/backend/InvenTree/plugin/base/ui/serializers.py b/src/backend/InvenTree/plugin/base/ui/serializers.py
index 18c69bb1af..fdfbc67148 100644
--- a/src/backend/InvenTree/plugin/base/ui/serializers.py
+++ b/src/backend/InvenTree/plugin/base/ui/serializers.py
@@ -18,6 +18,7 @@ class PluginPanelSerializer(serializers.Serializer):
             # Following fields are optional
             'icon',
             'content',
+            'context',
             'source',
         ]
 
@@ -43,6 +44,10 @@ class PluginPanelSerializer(serializers.Serializer):
         label=_('Panel Content (HTML)'), required=False, allow_blank=True
     )
 
+    context = serializers.JSONField(
+        label=_('Panel Context (JSON)'), required=False, allow_null=True, default=None
+    )
+
     source = serializers.CharField(
         label=_('Panel Source (javascript)'), required=False, allow_blank=True
     )
diff --git a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
index bbf356a5d7..6876653928 100644
--- a/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
+++ b/src/backend/InvenTree/plugin/samples/integration/user_interface_sample.py
@@ -1,7 +1,11 @@
 """Sample plugin which demonstrates user interface integrations."""
 
+import random
+import time
+
 from django.utils.translation import gettext_lazy as _
 
+from InvenTree.version import INVENTREE_SW_VERSION
 from part.models import Part
 from plugin import InvenTreePlugin
 from plugin.helpers import render_template, render_text
@@ -15,7 +19,7 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
     SLUG = 'sampleui'
     TITLE = 'Sample User Interface Plugin'
     DESCRIPTION = 'A sample plugin which demonstrates user interface integrations'
-    VERSION = '1.0'
+    VERSION = '1.1'
 
     SETTINGS = {
         'ENABLE_PART_PANELS': {
@@ -81,11 +85,18 @@ class SampleUserInterfacePlugin(SettingsMixin, UserInterfaceMixin, InvenTreePlug
             })
 
         # A dynamic panel which will be injected into the UI (loaded from external file)
+        # Note that we additionally provide some "context" data to the front-end render function
         if self.get_setting('ENABLE_DYNAMIC_PANEL'):
             panels.append({
                 'name': 'dynamic_panel',
                 'label': 'Dynamic Part Panel',
                 'source': '/static/plugin/sample_panel.js',
+                'context': {
+                    'version': INVENTREE_SW_VERSION,
+                    'plugin_version': self.VERSION,
+                    'random': random.randint(1, 100),
+                    'time': time.time(),
+                },
                 'icon': 'part',
             })
 
diff --git a/src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js b/src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js
index c0a0f5d1f4..9c382d8206 100644
--- a/src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js
+++ b/src/backend/InvenTree/plugin/samples/static/plugin/sample_panel.js
@@ -8,7 +8,7 @@
  * as well as dynamically hidden, based on the provided context.
  */
 
-export function renderPanel(target, context) {
+export function renderPanel(target, data) {
 
     if (!target) {
         console.error("No target provided to renderPanel");
@@ -22,13 +22,22 @@ export function renderPanel(target, context) {
     <p>It can be hidden or displayed based on the provided context.</p>
 
     <hr>
-    <h5>Context:</h5>
+    <h5>Client Context:</h5>
 
     <ul>
-    <li>Username: ${context.user.username()}</li>
-    <li>Is Staff: ${context.user.isStaff() ? "YES": "NO"}</li>
-    <li>Model Type: ${context.model}</li>
-    <li>Instance ID: ${context.id}</li>
+    <li>Username: ${data.user.username()}</li>
+    <li>Is Staff: ${data.user.isStaff() ? "YES": "NO"}</li>
+    <li>Model Type: ${data.model}</li>
+    <li>Instance ID: ${data.id}</li>
+    </ul>
+    <hr>
+    <h5>Server Context:</h5>
+    <ul>
+    <li>Server Version: ${data.context.version}</li>
+    <li>Plugin Version: ${data.context.plugin_version}</li>
+    <li>Random Number: ${data.context.random}</li>
+    <li>Time: ${data.context.time}</li>
+    </ul>
     `;
 
 }
diff --git a/src/frontend/src/components/plugins/PluginPanel.tsx b/src/frontend/src/components/plugins/PluginPanel.tsx
index 153a4cd1ab..431c9ba98c 100644
--- a/src/frontend/src/components/plugins/PluginPanel.tsx
+++ b/src/frontend/src/components/plugins/PluginPanel.tsx
@@ -13,6 +13,7 @@ export type PluginPanelProps = {
   label: string;
   icon?: string;
   content?: string;
+  context?: any;
   source?: string;
 };
 
@@ -82,7 +83,9 @@ export default function PluginPanelContent({
               func(ref.current, pluginContext);
               setError('');
             } catch (error) {
-              setError(t`Error occurred while rendering plugin content`);
+              setError(
+                t`Error occurred while rendering plugin content: ${error}`
+              );
             }
           } else {
             setError(t`Plugin did not provide panel rendering function`);
diff --git a/src/frontend/src/hooks/UsePluginPanels.tsx b/src/frontend/src/hooks/UsePluginPanels.tsx
index 3f991f86f9..1cf87f19ba 100644
--- a/src/frontend/src/hooks/UsePluginPanels.tsx
+++ b/src/frontend/src/hooks/UsePluginPanels.tsx
@@ -111,6 +111,11 @@ export function usePluginPanels({
         );
         const isHidden: boolean = panelState[identifier] ?? true;
 
+        const pluginContext: any = {
+          ...contextData,
+          context: props.context
+        };
+
         return {
           name: identifier,
           label: props.label,
@@ -118,7 +123,7 @@ export function usePluginPanels({
           content: (
             <PluginPanelContent
               pluginProps={props}
-              pluginContext={contextData}
+              pluginContext={pluginContext}
             />
           ),
           hidden: isHidden