diff --git a/src/components/debugger/components/JsonViewer.py b/src/components/debugger/components/JsonViewer.py
index 907d612..c9bd5d3 100644
--- a/src/components/debugger/components/JsonViewer.py
+++ b/src/components/debugger/components/JsonViewer.py
@@ -315,8 +315,6 @@ class JsonViewer(BaseComponent):
def __hash__(self):
return hash(self._key) if self._key is not None else super().__hash__()
-
-
@staticmethod
def add_quotes(value: str):
if '"' in value and "'" in value:
diff --git a/src/components/jsonviewer/JsonViewerApp.py b/src/components/jsonviewer/JsonViewerApp.py
new file mode 100644
index 0000000..3914bac
--- /dev/null
+++ b/src/components/jsonviewer/JsonViewerApp.py
@@ -0,0 +1,18 @@
+import logging
+
+from fasthtml.fastapp import fast_app
+
+from components.jsonviewer.constants import Routes
+from core.instance_manager import debug_session, InstanceManager
+
+jsonviwer_app, rt = fast_app()
+
+logger = logging.getLogger("JsonViewer")
+
+
+@rt(Routes.Fold)
+def post(session, _id: str, node_id: str, folding: str):
+ logger.debug(f"Entering {Routes.Fold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
+ instance = InstanceManager.get(session, _id)
+ instance.set_node_folding(node_id, folding)
+ return instance.render_node(node_id)
\ No newline at end of file
diff --git a/src/components/jsonviewer/__init__.py b/src/components/jsonviewer/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/jsonviewer/assets/__init__.py b/src/components/jsonviewer/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/jsonviewer/assets/icons.py b/src/components/jsonviewer/assets/icons.py
new file mode 100644
index 0000000..d5e7c47
--- /dev/null
+++ b/src/components/jsonviewer/assets/icons.py
@@ -0,0 +1,27 @@
+from fastcore.basics import NotStr
+
+# Fluent CaretRight20Filled
+icon_collapsed = NotStr("""""")
+
+# Fluent CaretDown20Filled
+icon_expanded = NotStr("""""")
+
+icon_class = NotStr("""
+""")
diff --git a/src/components/jsonviewer/commands.py b/src/components/jsonviewer/commands.py
new file mode 100644
index 0000000..15d7966
--- /dev/null
+++ b/src/components/jsonviewer/commands.py
@@ -0,0 +1,23 @@
+from components.jsonviewer.constants import ROUTE_ROOT, Routes
+
+
+class JsonViewerCommands:
+ def __init__(self, owner):
+ self._owner = owner
+ self._id = owner.get_id()
+
+ def fold(self, node_id: str, folding: str):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.Fold}",
+ "hx-target": f"#{node_id}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
+ }
+
+ def open_digest(self, user_id, digest):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.DbEngineDigest}",
+ "hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
+ }
\ No newline at end of file
diff --git a/src/components/jsonviewer/components/JsonViewer.py b/src/components/jsonviewer/components/JsonViewer.py
new file mode 100644
index 0000000..eec584c
--- /dev/null
+++ b/src/components/jsonviewer/components/JsonViewer.py
@@ -0,0 +1,324 @@
+from dataclasses import dataclass, field
+from typing import Any
+
+from fasthtml.components import *
+from pandas import DataFrame
+
+from components.BaseComponent import BaseComponentMultipleInstance
+from components.datagrid_new.components.DataGrid import DataGrid
+from components.jsonviewer.assets.icons import icon_expanded, icon_collapsed, icon_class
+from components.jsonviewer.commands import JsonViewerCommands
+from components.jsonviewer.constants import NODES_KEYS_TO_NOT_EXPAND, NODE_OBJECT, INDENT_SIZE, MAX_TEXT_LENGTH
+from components_helpers import apply_boundaries
+from core.serializer import TAG_OBJECT
+
+
+class FoldingMode:
+ COLLAPSE = "collapse"
+ EXPAND = "expand"
+
+
+@dataclass
+class Node:
+ value: Any
+
+
+@dataclass
+class ValueNode(Node):
+ hint: str = None
+
+
+@dataclass
+class ListNode(Node):
+ node_id: str
+ level: int
+ children: list[Node] = field(default_factory=list)
+
+
+@dataclass
+class DictNode(Node):
+ node_id: str
+ level: int
+ children: dict[str, Node] = field(default_factory=dict)
+
+
+class JsonViewerHelper:
+ class_string = f"mmt-jsonviewer-string"
+ class_bool = f"mmt-jsonviewer-bool"
+ class_number = f"mmt-jsonviewer-number"
+ class_null = f"mmt-jsonviewer-null"
+ class_digest = f"mmt-jsonviewer-digest"
+ class_object = f"mmt-jsonviewer-object"
+ class_dataframe = f"mmt-jsonviewer-dataframe"
+
+ @staticmethod
+ def is_sha256(_value):
+ return (isinstance(_value, str) and
+ len(_value) == 64 and
+ all(c in '0123456789abcdefABCDEF' for c in _value))
+
+
+class JsonViewer(BaseComponentMultipleInstance):
+ COMPONENT_INSTANCE_ID = "Jsonviewer"
+
+ def __init__(self, session, _id, data=None, hooks=None, key=None, boundaries=None):
+ super().__init__(session, _id)
+ self._key = key # for comparison between two jsonviewer components
+ self.data = data
+ self._node_id = -1
+ self._boundaries = boundaries if boundaries else {"height": "600"}
+ self._commands = JsonViewerCommands(self)
+
+ # A little explanation on how the folding / unfolding work
+ # all the nodes are either fold or unfold... except if there are not
+ # self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
+ # self._nodes_to_track keeps track of the exceptions
+ # The idea is to minimize the memory usage
+ self._folding_mode = FoldingMode.COLLAPSE
+ self._nodes_to_track = set() # all nodes that are expanded when _fold_mode and vice versa
+
+ self._nodes_by_id = {}
+
+ self.node = self._create_node(None, data)
+
+ # hooks are used to define specific rendering
+ # They are tuple (Predicate, Element to render (eg Div))
+ self.hooks = hooks or []
+
+ self._helper = JsonViewerHelper()
+
+ def set_data(self, data):
+ self.data = data
+ self.node = self._create_node(None, data)
+
+ def set_node_folding(self, node_id, folding):
+ if folding == self._folding_mode:
+ self._nodes_to_track.remove(node_id)
+ else:
+ self._nodes_to_track.add(node_id)
+
+ def render_node(self, node_id):
+ key, node = self._nodes_by_id[node_id]
+ return self._render_node(key, node)
+
+ def set_folding_mode(self, folding_mode):
+ self._folding_mode = folding_mode
+ self._nodes_to_track.clear()
+
+ def get_folding_mode(self):
+ return self._folding_mode
+
+ def open_digest(self, user_id: str, digest: str):
+ return self._owner.db_engine_headers(user_id, digest)
+
+ def _create_node(self, key, data, level=0):
+ if isinstance(data, list):
+ node_id = self._get_next_id()
+ if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
+ self._nodes_to_track.add(node_id)
+ node = ListNode(data, node_id, level)
+ self._nodes_by_id[node_id] = (key, node)
+ for index, item in enumerate(data):
+ node.children.append(self._create_node(index, item, level + 1))
+
+ elif isinstance(data, dict):
+ node_id = self._get_next_id()
+ if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
+ self._nodes_to_track.add(node_id)
+ node = DictNode(data, node_id, level)
+ self._nodes_by_id[node_id] = (key, node)
+ for key, value in data.items():
+ node.children[key] = self._create_node(key, value, level + 1)
+
+ else:
+ if key == TAG_OBJECT:
+ hint = NODE_OBJECT
+ else:
+ hint = None
+ node = ValueNode(data, hint)
+
+ return node
+
+ def _must_expand(self, node):
+ if not isinstance(node, (ListNode, DictNode)):
+ return None
+
+ if self._folding_mode == FoldingMode.COLLAPSE:
+ return node.node_id in self._nodes_to_track
+ else:
+ return node.node_id not in self._nodes_to_track
+
+ def _mk_folding(self, node: Node, must_expand: bool | None):
+ if must_expand is None:
+ return None
+
+ return Span(icon_expanded if must_expand else icon_collapsed,
+ cls="icon-16-inline mmt-jsonviewer-folding",
+ style=f"margin-left: -{INDENT_SIZE}px;",
+ **self._commands.fold(node.node_id, FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND)
+ )
+
+ def _get_next_id(self):
+ self._node_id += 1
+ return f"{self._id}-{self._node_id}"
+
+ def _render_value(self, key, node, must_expand):
+ if must_expand is False:
+ return Span("[...]" if isinstance(node, ListNode) else "{...}",
+ id=node.node_id,
+ **self._commands.fold(node.node_id, FoldingMode.EXPAND))
+
+ for predicate, renderer in self.hooks:
+ if predicate(key, node, self._helper):
+ return renderer(key, node, self._helper)
+
+ if isinstance(node, DictNode):
+ return self._render_dict(key, node)
+ elif isinstance(node, ListNode):
+ return self._render_list(key, node)
+ else:
+ data_tooltip = None
+ htmx_params = {}
+ icon = None
+
+ if isinstance(node.value, bool): # order is important bool is an int in Python !
+ str_value = "true" if node.value else "false"
+ data_class = "bool"
+ elif isinstance(node.value, (int, float)):
+ str_value = str(node.value)
+ data_class = "number"
+ elif node.value is None:
+ str_value = "null"
+ data_class = "null"
+ elif self._helper.is_sha256(node.value):
+ str_value = str(node.value)
+ data_class = "digest"
+ htmx_params = self._commands.open_digest(self.user_id, node.value)
+ elif node.hint == NODE_OBJECT:
+ icon = icon_class
+ str_value = node.value.split(".")[-1]
+ data_class = "object"
+ elif isinstance(node.value, DataFrame):
+ dg = DataGrid(self._session)
+ dg.init_from_dataframe(node.value)
+ str_value = dg
+ data_class = "dataframe"
+ else:
+ as_str = str(node.value)
+ if len(as_str) > MAX_TEXT_LENGTH:
+ str_value = as_str[:MAX_TEXT_LENGTH] + "..."
+ data_tooltip = as_str
+ else:
+ str_value = as_str
+ str_value = self.add_quotes(str_value)
+ data_class = "string"
+
+ if data_tooltip is not None:
+ cls = f"mmt-jsonviewer-{data_class} mmt-tooltip"
+ else:
+ cls = f"mmt-jsonviewer-{data_class}"
+
+ if icon is not None:
+ return Span(Span(icon, cls="icon-16-inline mr-1"),
+ Span(str_value, data_tooltip=data_tooltip, **htmx_params),
+ cls=cls)
+
+ return Span(str_value, cls=cls, data_tooltip=data_tooltip, **htmx_params)
+
+ def _render_dict(self, key, node: DictNode):
+ return Span("{",
+ *[
+ self._render_node(child_key, value)
+ for child_key, value in node.children.items()
+ ],
+ Div("}"),
+ id=node.node_id)
+
+ def _render_list(self, key, node: ListNode):
+ def _all_the_same(_key, _node):
+ if len(_node.children) == 0:
+ return False
+
+ sample_node = _node.children[0]
+ sample_value = sample_node.value
+
+ if sample_value is None:
+ return False
+
+ type_ = type(sample_value)
+ if type_ in (int, float, str, bool, list, dict, ValueNode):
+ return False
+
+ # a specific rendering is specified
+ for predicate, renderer in self.hooks:
+ if predicate(_key, sample_node, self._helper):
+ return False
+
+ return all(type(item.value) == type_ for item in _node.children)
+
+ def _render_as_grid(_node):
+ type_ = type(_node.children[0].value)
+ icon = icon_class
+ str_value = type_.__name__.split(".")[-1]
+
+ data = [child.value.__dict__ for child in _node.children]
+ df = DataFrame(data)
+ dg = DataGrid(self._session)
+ dg.init_from_dataframe(df)
+ return Span(Span(Span(icon, cls="icon-16-inline mr-1"), Span(str_value), cls="mmt-jsonviewer-object"),
+ dg,
+ id=_node.node_id)
+
+ def _render_as_list(_node):
+ return Span("[",
+ *[
+ self._render_node(index, item)
+ for index, item in enumerate(_node.children)
+ ],
+ Div("]"),
+ )
+
+ return _render_as_grid(node) if _all_the_same(key, node) else _render_as_list(node)
+
+ def _render_node(self, key, node):
+ must_expand = self._must_expand(node) # to be able to update the folding when the node is updated
+ return Div(
+
+ self._mk_folding(node, must_expand),
+ Span(f'{key} : ') if key is not None else None,
+ self._render_value(key, node, must_expand),
+
+ style=f"margin-left: {INDENT_SIZE}px;",
+ id=node.node_id if hasattr(node, "node_id") else None,
+ )
+
+ def __ft__(self):
+ return Div(
+ Div(self._render_node(None, self.node),
+ id=f"{self._id}-root",
+ style="margin-left: 0px;"),
+ cls="mmt-jsonviewer",
+ id=f"{self._id}",
+ **apply_boundaries(self._boundaries),
+ )
+
+ def __eq__(self, other):
+ if type(other) is type(self):
+ return self._key is not None and self._key == other._key
+ else:
+ return False
+
+ def __hash__(self):
+ return hash(self._key) if self._key is not None else super().__hash__()
+
+ @staticmethod
+ def add_quotes(value: str):
+ if '"' in value and "'" in value:
+ # Value contains both double and single quotes, escape double quotes
+ return f'"{value.replace("\"", "\\\"")}"'
+ elif '"' in value:
+ # Value contains double quotes, use single quotes
+ return f"'{value}'"
+ else:
+ # Default case, use double quotes
+ return f'"{value}"'
diff --git a/src/components/jsonviewer/components/__init__.py b/src/components/jsonviewer/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/jsonviewer/constants.py b/src/components/jsonviewer/constants.py
new file mode 100644
index 0000000..c1f0e6e
--- /dev/null
+++ b/src/components/jsonviewer/constants.py
@@ -0,0 +1,10 @@
+ROUTE_ROOT = "/jsonviewer"
+
+INDENT_SIZE = 20
+MAX_TEXT_LENGTH = 50
+
+NODE_OBJECT = "Object"
+NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
+
+class Routes:
+ Fold = "/fold"
diff --git a/src/components/workflows/components/WorkflowDesignerProperties.py b/src/components/workflows/components/WorkflowDesignerProperties.py
index d7db313..6993de1 100644
--- a/src/components/workflows/components/WorkflowDesignerProperties.py
+++ b/src/components/workflows/components/WorkflowDesignerProperties.py
@@ -1,8 +1,8 @@
from fasthtml.common import *
from components.BaseComponent import BaseComponent
-from components.debugger.components.JsonViewer import JsonViewer
from components.entryselector.components.EntrySelector import EntrySelector
+from components.jsonviewer.components.JsonViewer import JsonViewer
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
from components_helpers import mk_dialog_buttons
from core.instance_manager import InstanceManager
@@ -33,15 +33,9 @@ class WorkflowDesignerProperties(BaseComponent):
hooks={
"on_entry_selected": self.on_entry_selector_changed})
self._input_jsonviewer: JsonViewer = InstanceManager.new(self._session,
- JsonViewer,
- owner=self,
- user_id=None,
- data=None)
+ JsonViewer)
self._output_jsonviewer: JsonViewer = InstanceManager.new(self._session,
- JsonViewer,
- owner=self,
- user_id=None,
- data=None)
+ JsonViewer)
def set_entry_selector_data(self, data):
self.entry_selector.set_data(data)
diff --git a/src/main.py b/src/main.py
index ecdd6dc..0649500 100644
--- a/src/main.py
+++ b/src/main.py
@@ -147,6 +147,7 @@ register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
register_component("undo_redo", "components.undo_redo", "UndoRedoApp")
register_component("tabs", "components.tabs", "TabsApp") # before repositories
register_component("entryselector", "components.entryselector", "EntrySelectorApp")
+register_component("jsonviewer", "components.jsonviewer", "JsonViewerApp")
register_component("applications", "components.applications", "ApplicationsApp")
register_component("repositories", "components.repositories", "RepositoriesApp")
register_component("workflows", "components.workflows", "WorkflowsApp")