diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..03a7a4a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+
+# Copy requirements first for better caching
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+
+# Copy the source code
+COPY src/ /app/src/
+COPY src/logging.yaml /app/
+
+
+# Set PYTHONPATH to include the src directory
+ENV PYTHONPATH=/app/src
+ENV ADMIN_EMAIL="admin"
+ENV ADMIN_PASSWORD="admin"
+
+
+# Command to run your application
+# Adjust this to your actual entry point script
+CMD ["python", "-m", "src.main"]
\ No newline at end of file
diff --git a/README.md b/README.md
index c72a29f..e06553e 100644
--- a/README.md
+++ b/README.md
@@ -7,4 +7,31 @@ using [FastHTML](https://www.fastht.ml/) to serve the front end
```commandline
cd src
python main.py
+```
+
+# Using Docker
+1. **Build and start the services**:
+```shell
+docker-compose up -d
+```
+The application will be accessible on port 8000 (or whatever port you configured).
+
+2. **Initialize the Mistral model** (first run):
+```shell
+docker-compose exec ollama ollama pull mistral:7b-instruct
+```
+
+1. **Check logs**:
+```shell
+docker-compose logs -f
+```
+
+1. **Stop the services**:
+```shell
+docker-compose down
+```
+
+1. **Rebuild**:
+```shell
+docker-compose build
```
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..298c5bb
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "8001:5001"
+ volumes:
+ - ./src:/app/src
+ depends_on:
+ - ollama
+ environment:
+ - OLLAMA_HOST=http://ollama:11434
+ - PYTHONPATH=/app/src
+ networks:
+ - app-network
+
+ ollama:
+ image: ollama/ollama:latest
+ ports:
+ - "11434:11434"
+ volumes:
+ - ollama_data:/root/.ollama
+ command: serve
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+ networks:
+ - app-network
+
+networks:
+ app-network:
+ driver: bridge
+
+volumes:
+ ollama_data:
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 9baf79b..28d440e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -31,4 +31,5 @@ watchfiles==0.24.0
websockets==13.1
pandas~=2.2.3
-numpy~=2.1.1
\ No newline at end of file
+numpy~=2.1.1
+requests~=2.32.3
\ No newline at end of file
diff --git a/src/assets/css.py b/src/assets/css.py
deleted file mode 100644
index 3172624..0000000
--- a/src/assets/css.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from fasthtml.components import Style
-
-my_managing_tools_style = Style("""
-.icon-32 {
- width: 32px;
- height: 32px;
-}
-
-.icon-32 svg {
- width: 100%;
- height: 100%;
-}
-
-.icon-24 {
- width: 24px;
- min-width: 24px;
- height: 24px;
-}
-
-.icon-24 svg {
- width: 100%;
- height: 100%;
-}
-
-.icon-20 {
- width: 20px;
- min-width: 20px;
- height: 20px;
- margin-top: auto;
- margin-bottom: auto;
-}
-
-
-.icon-16 {
- width: 16px;
- min-width: 16px;
- height: 16px;
- margin-top: auto;
- margin-bottom: 4px;
-}
-
-.icon-bool {
- display: block;
- width: 20px;
- height: 20px;
- margin: auto;
-}
-
-.icon-btn {
- cursor: pointer;
-}
-
-
-.cursor-pointer {
- cursor: pointer;
-}
-
-.cursor-default {
- cursor: default;
-}
-
-""")
diff --git a/src/assets/main.css b/src/assets/main.css
index 4658327..d42db94 100644
--- a/src/assets/main.css
+++ b/src/assets/main.css
@@ -1,7 +1,11 @@
:root {
--theme-controller-zindex: 1000;
+ --datagrid-menu-zindex: 910;
--datagrid-sidebar-zindex: 900;
--datagrid-scrollbars-zindex: 800;
+ --mmt-tooltip-zindex: 10;
+ --datagrid-drag-drop-zindex: 5;
+ --datagrid-resize-zindex: 1;
}
.mmt-tooltip-container {
@@ -15,11 +19,86 @@
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: fixed; /* Keep it above other content and adjust position */
- z-index: 10; /* Ensure it's on top */
+ z-index: var(--mmt-tooltip-zindex); /* Ensure it's on top */
}
.mmt-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
+}
+
+.icon-32 {
+ width: 32px;
+ height: 32px;
+}
+
+.icon-32 svg {
+ width: 100%;
+ height: 100%;
+}
+
+.icon-24 {
+ width: 24px;
+ min-width: 24px;
+ height: 24px;
+}
+
+.icon-24 svg {
+ width: 100%;
+ height: 100%;
+}
+
+.icon-20 {
+ width: 20px;
+ min-width: 20px;
+ height: 20px;
+ margin-top: auto;
+ margin-bottom: auto;
+}
+
+.icon-20-inline {
+ display: inline-block;
+ width: 20px;
+ min-width: 20px;
+ height: 20px;
+ padding-top: 4px;
+}
+
+
+.icon-16 {
+ width: 16px;
+ min-width: 16px;
+ height: 16px;
+ margin-top: auto;
+ margin-bottom: 4px;
+}
+
+.icon-16-inline {
+ display: inline-block;
+ width: 16px;
+ min-width: 16px;
+ height: 16px;
+ padding-top: 5px;
+}
+
+
+.icon-bool {
+ display: block;
+ width: 20px;
+ height: 20px;
+ margin: auto;
+}
+
+.icon-btn {
+ cursor: pointer;
+}
+
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.cursor-default {
+ cursor: default;
}
\ No newline at end of file
diff --git a/src/assets/main.js b/src/assets/main.js
index 43d7e4e..a0a320b 100644
--- a/src/assets/main.js
+++ b/src/assets/main.js
@@ -1,19 +1,31 @@
-function bindTooltipsWithDelegation(elementId) {
+const tooltipElementId = "mmt-app"
+
+function bindTooltipsWithDelegation() {
+ const elementId = tooltipElementId
console.debug("bindTooltips on element " + elementId);
const element = document.getElementById(elementId);
const tooltipContainer = document.getElementById(`tt_${elementId}`);
- if (!element || !tooltipContainer) {
- console.error("Invalid element or tooltip container");
+
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ if (!tooltipContainer) {
+ console.error(`Invalid tooltip 'tt_${elementId}' container.`);
return;
}
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
- const cell = event.target.closest("div[data-tooltip]");
+ const cell = event.target.closest("[data-tooltip]");
if (!cell) return;
+ const no_tooltip = element.hasAttribute("mmt-no-tooltip");
+ if (no_tooltip) return;
+
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains("mmt-tooltip");
@@ -34,18 +46,46 @@ function bindTooltipsWithDelegation(elementId) {
}
// Apply styles for tooltip positioning
- tooltipContainer.textContent = tooltipText;
- tooltipContainer.setAttribute("data-visible", "true");
- tooltipContainer.style.top = `${top}px`;
- tooltipContainer.style.left = `${left}px`;
+ requestAnimationFrame(() => {
+ tooltipContainer.textContent = tooltipText;
+ tooltipContainer.setAttribute("data-visible", "true");
+ tooltipContainer.style.top = `${top}px`;
+ tooltipContainer.style.left = `${left}px`;
+ });
}
}
}, true); // Use capture phase for better delegation if needed
element.addEventListener("mouseleave", (event) => {
- const cell = event.target.closest("div[data-tooltip]");
+ const cell = event.target.closest("[data-tooltip]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Use capture phase for better delegation if needed
+}
+
+function disableTooltip() {
+ const elementId = tooltipElementId
+ // console.debug("disableTooltip on element " + elementId);
+
+ const element = document.getElementById(elementId);
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ element.setAttribute("mmt-no-tooltip", "");
+}
+
+function enableTooltip() {
+ const elementId = tooltipElementId
+ // console.debug("enableTooltip on element " + elementId);
+
+ const element = document.getElementById(elementId);
+ if (!element) {
+ console.error(`Invalid element '${elementId}' container`);
+ return;
+ }
+
+ element.removeAttribute("mmt-no-tooltip");
}
\ No newline at end of file
diff --git a/src/components/BaseCommandManager.py b/src/components/BaseCommandManager.py
new file mode 100644
index 0000000..ef88fd4
--- /dev/null
+++ b/src/components/BaseCommandManager.py
@@ -0,0 +1,36 @@
+class BaseCommandManager:
+ def __init__(self, owner):
+ self._owner = owner
+ self._id = owner.get_id()
+
+ @staticmethod
+ def merge(*items):
+ """
+ Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
+ If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
+ If the key's value is not a string, an error is raised.
+
+ :param items: dictionaries to be merged. If all items are None, None is returned.
+ :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
+ :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
+ """
+ if all(item is None for item in items):
+ return None
+
+ res = {}
+ for item in [item for item in items if item is not None]:
+
+ for key, value in item.items():
+ if not key in res:
+ res[key] = value
+ else:
+ if isinstance(res[key], str):
+ res[key] += " " + value
+ else:
+ raise NotImplementedError("")
+
+ return res
+
+ @staticmethod
+ def merge_class(cls1, cls2):
+ return (cls1 + " " + cls2) if cls2 else cls1
\ No newline at end of file
diff --git a/src/components/BaseComponent.py b/src/components/BaseComponent.py
index bd70bcf..eae43c6 100644
--- a/src/components/BaseComponent.py
+++ b/src/components/BaseComponent.py
@@ -10,6 +10,9 @@ class BaseComponent:
def get_id(self):
return self._id
+ def get_session(self):
+ return self._session
+
def __repr__(self):
return self._id
diff --git a/src/components/addstuff/assets/addstuff.js b/src/components/addstuff/assets/addstuff.js
deleted file mode 100644
index b9bd2a5..0000000
--- a/src/components/addstuff/assets/addstuff.js
+++ /dev/null
@@ -1,3 +0,0 @@
-function bindRepositories(repositoryId) {
- bindTooltipsWithDelegation(repositoryId)
-}
\ No newline at end of file
diff --git a/src/components/addstuff/commands.py b/src/components/addstuff/commands.py
deleted file mode 100644
index 42615a1..0000000
--- a/src/components/addstuff/commands.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from components.addstuff.constants import ROUTE_ROOT, Routes
-
-
-class Commands:
- def __init__(self, owner):
- self._owner = owner
- self._id = owner.get_id()
-
- def request_add_table(self, repository_name):
- return {
- "hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
- "hx-target": f"#{self._owner.tabs_manager.get_id()}",
- "hx-swap": "outerHTML",
- "hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
- }
-
- def add_table(self):
- return {
- "hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
- "hx-target": f"#{self._owner.tabs_manager.get_id()}",
- "hx-swap": "outerHTML",
- # The repository_name and the table_name will be given by the form
- }
diff --git a/src/components/addstuff/components/AddStuffMenu.py b/src/components/addstuff/components/AddStuffMenu.py
index 6c3b4a0..fc2c5b6 100644
--- a/src/components/addstuff/components/AddStuffMenu.py
+++ b/src/components/addstuff/components/AddStuffMenu.py
@@ -1,26 +1,22 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
-from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ROUTE_ROOT, Routes
-from components.addstuff.settings import RepositoriesDbManager
+from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
+from components.repositories.components.Repositories import Repositories
+from core.instance_manager import InstanceManager
class AddStuffMenu(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
self.tabs_manager = tabs_manager # MyTabs component id
- self.mappings = {} # to keep track of when element is displayed on which tab
- self.settings = RepositoriesDbManager(session, settings_manager)
+ self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
def __ft__(self):
return Div(
Div("Add stuff...", tabindex="0"),
Ul(
- Li(A("Add Database",
- hx_get=f"{ROUTE_ROOT}{Routes.AddRepository}",
- hx_target=f"#{self.tabs_manager.get_id()}",
- hx_swap="outerHTML",
- )),
+ Li(A("Add Database", **self.repositories.commands.request_add_repository())),
Li(A("Add Application")),
tabindex="0",
cls="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm"
diff --git a/src/components/addstuff/constants.py b/src/components/addstuff/constants.py
index fd904fd..eaee462 100644
--- a/src/components/addstuff/constants.py
+++ b/src/components/addstuff/constants.py
@@ -1,10 +1 @@
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
-ADD_DATABASE_INSTANCE_ID = "__AddDatabase__"
-REPOSITORIES_INSTANCE_ID = "__Repositories__"
-ROUTE_ROOT = "/add"
-
-class Routes:
- AddRepository = "/add-repository"
- SelectRepository = "/select-repository"
- AddTable = "/add-table"
- ShowTable = "/show-table"
\ No newline at end of file
diff --git a/src/components/addstuff/settings.py b/src/components/addstuff/settings.py
deleted file mode 100644
index 8e25cf9..0000000
--- a/src/components/addstuff/settings.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import dataclasses
-import logging
-
-from core.settings_management import SettingsManager
-from core.settings_objects import BaseSettingObj
-
-ADD_STUFF_SETTINGS_ENTRY = "AddStuffSettings"
-REPOSITORIES_SETTINGS_ENTRY = "Repositories"
-
-logger = logging.getLogger("AddStuffSettings")
-
-
-@dataclasses.dataclass
-class Repository:
- name: str
- tables: list[str]
-
-
-@dataclasses.dataclass
-class RepositoriesSettings:
- repositories: list[Repository] = dataclasses.field(default_factory=list)
- selected_repository_name: str = None
-
-
-class RepositoriesDbManager:
-
- def __init__(self, session: dict, settings_manager: SettingsManager):
- self.session = session
- self.settings_manager = settings_manager
-
- def _get_settings(self):
- return self.settings_manager.get(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
-
- def add_repository(self, repository_name: str, tables: list[str] = None):
- """
- Adds a new repository to the list of repositories. The repository is identified
- by its name and can optionally include a list of associated tables.
-
- :param repository_name: The name of the repository to be added.
- :param tables: A list of Table objects to be associated with the repository,
- defaulting to an empty list if not provided.
- :type tables: list[Table], optional
- :return: None
- """
-
- settings = self._get_settings()
-
- if repository_name is None or repository_name == "":
- raise ValueError("Repository name cannot be empty.")
-
- if repository_name in [repo.name for repo in settings.repositories]:
- raise ValueError(f"Repository '{repository_name}' already exists.")
-
- existing_repositories = [r.name for r in settings.repositories]
- logger.info(f"Existing repositories:{existing_repositories}")
-
- repository = Repository(repository_name, tables or [])
- settings.repositories.append(repository)
- self.settings_manager.put(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
- return repository
-
- def get_repository(self, repository_name: str):
- if repository_name is None or repository_name == "":
- raise ValueError("Repository name cannot be empty.")
-
- settings = self._get_settings()
- if repository_name not in [repo.name for repo in settings.repositories]:
- raise ValueError(f"Repository '{repository_name}' does not exists.")
-
- return next(filter(lambda r: r.name == repository_name, settings.repositories))
-
- def modify_repository(self, repository_name: str, tables: list[str]):
- repository = self.get_repository(repository_name)
-
-
- def get_repositories(self):
- return self._get_settings().repositories
-
- def add_table(self, repository_name: str, table_name: str, table_settings: dict):
- """
- Adds a table to the specified repository
-
- :param repository_name: The name of the target repository.
- :param table_name: The name of the table to add.
- :param table_settings: A dictionary containing the settings or configuration details
- of the table.
- :return: None
- """
- settings = self._get_settings()
-
- repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
- if repository is None:
- raise ValueError(f"Repository '{repository_name}' does not exists.")
-
- if table_name in (t.name for t in repository.tables):
- raise ValueError(f"Table '{table_name}' already exists.")
-
- repository.tables.append(MyTable(table_name, table_settings))
- self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
-
- def select_repository(self, repository_name: str):
- """
- Select and save the specified repository name in the current session's settings.
-
- :param repository_name: The name of the repository to be selected and stored.
- :type repository_name: str
- :return: None
- """
- settings = self._get_settings()
- settings.selected_repository_name = repository_name
- self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
-
\ No newline at end of file
diff --git a/src/components/datagrid/DataGrid.py b/src/components/datagrid/DataGrid.py
index 8d814a6..087bf10 100644
--- a/src/components/datagrid/DataGrid.py
+++ b/src/components/datagrid/DataGrid.py
@@ -11,7 +11,7 @@ from components.datagrid.DataGridCommandManager import DataGridCommandManager
from components.datagrid.constants import *
from components.datagrid.icons import *
from core.utils import append_once, from_bool
-from core.utils import make_html_id, make_column_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
+from core.utils import make_html_id, make_safe_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
logger = logging.getLogger("DataGrid")
@@ -1697,16 +1697,16 @@ class DataGrid:
if (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is not None:
res[DG_COLUMNS] = {
- make_column_id(col): {INDEX_KEY: index,
- TITLE_KEY: col,
+ make_safe_id(col): {INDEX_KEY: index,
+ TITLE_KEY: col,
"type": dtype_mapping.get(dtype.name, DG_DATATYPE_STRING), }
for index, (col, dtype) in enumerate(zip(df.columns, df.dtypes))
}
elif (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is None and columns is not None:
res[DG_COLUMNS] = {
- make_column_id(col): {INDEX_KEY: index,
- TITLE_KEY: snake_case_to_capitalized_words(col)}
+ make_safe_id(col): {INDEX_KEY: index,
+ TITLE_KEY: snake_case_to_capitalized_words(col)}
for index, col in enumerate(columns)
}
@@ -1717,7 +1717,7 @@ class DataGrid:
if isinstance(res[DG_COLUMNS], list):
# set grid settings from list
res[DG_COLUMNS] = {
- make_column_id(col): {INDEX_KEY: index, TITLE_KEY: col}
+ make_safe_id(col): {INDEX_KEY: index, TITLE_KEY: col}
for index, col in enumerate(res[DG_COLUMNS])
}
diff --git a/src/components/datagrid_new/DataGridApp.py b/src/components/datagrid_new/DataGridApp.py
index 7f3420b..b6a7982 100644
--- a/src/components/datagrid_new/DataGridApp.py
+++ b/src/components/datagrid_new/DataGridApp.py
@@ -125,7 +125,13 @@ def post(session, _id: str, key: str, arg: str = None):
@rt(Routes.OnClick)
-def post(session, _id: str, cell_id: str = None, modifier: str = None):
- logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}")
+def post(session, _id: str, cell_id: str = None, modifier: str = None, boundaries: str=None):
+ logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
- return instance.manage_click(cell_id, modifier)
+ return instance.manage_click(cell_id, modifier, json.loads(boundaries) if boundaries else None)
+
+@rt(Routes.UpdateState)
+def post(session, _id: str, state: str, args: str = None):
+ logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.manage_state_changed(state, args)
\ No newline at end of file
diff --git a/src/components/datagrid_new/Readme.md b/src/components/datagrid_new/Readme.md
index a057d87..e7e43c7 100644
--- a/src/components/datagrid_new/Readme.md
+++ b/src/components/datagrid_new/Readme.md
@@ -4,16 +4,18 @@
using `Datagrid(id=my_id)`
-| Name | value |
-|--------------------------------|----------------------------------------------------------------|
-| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
-| filter all | `fa_{datagrid_id}` |
-| file upload | `fu_{datagrid_id}` |
-| sidebar | `sb_{datagrid_id}` |
-| scroll bars | `scb_{datagrid_id}` |
-| Settings columns | `scol_{datagrid_id}` |
-| table | `t_{datagrid_id}` |
-| table cell drop down | `tcdd_{datagrid_id}` |
-| table drag and drop info | `tdd_{datagrid_id}` |
-| views selection component | `v_{datagrid_id}` |
+| Name | value |
+|---------------------------|----------------------------------------------------------------|
+| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
+| filter all | `fa_{datagrid_id}` |
+| file upload | `fu_{datagrid_id}` |
+| footer menu | `fm_{datagrid_id}` |
+| sidebar | `sb_{datagrid_id}` |
+| scroll bars | `scb_{datagrid_id}` |
+| Settings columns | `scol_{datagrid_id}` |
+| table | `t_{datagrid_id}` |
+| table container | `tc_{datagrid_id}` |
+| table cell menu | `tcm_{datagrid_id}` |
+| table drag and drop info | `tdd_{datagrid_id}` |
+| views selection component | `v_{datagrid_id}` |
\ No newline at end of file
diff --git a/src/components/datagrid_new/assets/Datagrid.css b/src/components/datagrid_new/assets/Datagrid.css
index 2b8205b..af150d7 100644
--- a/src/components/datagrid_new/assets/Datagrid.css
+++ b/src/components/datagrid_new/assets/Datagrid.css
@@ -6,7 +6,7 @@ input:focus {
display: none;
position: absolute;
top: 100%;
- z-index: 5;
+ z-index: var(--datagrid-drag-drop-zindex);
width: 100px;
border: 1px solid var(--color-base-300);
border-radius: 10px;
@@ -20,8 +20,8 @@ input:focus {
}
.dt2-main {
- position: relative;
height: 100%;
+ position: relative;
}
.dt2-sidebar {
@@ -46,80 +46,77 @@ input:focus {
transition: opacity 0.3s ease;
}
+.dt2-container {
+ position: relative;
+}
+
.dt2-scrollbars {
- bottom: 0;
- left: 0;
- pointer-events: none; /* Ensures parents don't intercept pointer events */
position: absolute;
+ top: 24px;
+ bottom: 0px;
+ left: 0;
right: 0;
- top: 0;
+ pointer-events: none; /* Ensures parents don't intercept pointer events */
z-index: var(--datagrid-scrollbars-zindex);
}
-/* Scrollbar Wrappers */
-.dt2-scrollbars-vertical-wrapper {
- bottom: 3px;
- left: auto;
- position: absolute;
- right: 3px;
- top: 3px;
- width: 8px;
- background-color: var(--color-base-200)
-}
-
-.dt2-scrollbars-horizontal-wrapper {
- bottom: -12px;
- height: 8px;
- left: 3px;
- position: absolute;
- right: 3px;
- top: auto;
- background-color: var(--color-base-200)
-}
-
-/* Vertical Scrollbar */
-.dt2-scrollbars-vertical {
- bottom: auto;
- left: 0;
- position: absolute;
- right: 0;
- top: auto;
- background-color: var(--color-base-300);
- border-radius: 3px; /* Rounded corners */
- pointer-events: auto; /* Enable interaction */
- cursor: pointer;
- width: 100%; /* Fits inside its wrapper */
-}
-
-/* Horizontal Scrollbar */
-.dt2-scrollbars-horizontal {
- bottom: 0;
- left: auto;
- position: absolute;
- right: auto;
- top: 0;
- background-color: var(--color-base-300);
- border-radius: 3px; /* Rounded corners */
- pointer-events: auto; /* Enable interaction */
- cursor: pointer;
- height: 100%; /* Fits inside its wrapper */
-}
-
-/* Scrollbar wrappers are hidden by default */
+/* Scrollbar Wrappers common attributes*/
.dt2-scrollbars-vertical-wrapper,
.dt2-scrollbars-horizontal-wrapper {
+ position: absolute;
+ background-color: var(--color-base-200)
opacity: 1;
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
pointer-events: auto; /* Allow interaction */
}
+/* Scrollbar Wrappers */
+.dt2-scrollbars-vertical-wrapper {
+ left: auto;
+ right: 3px;
+ top: 3px;
+ bottom: 3px;
+ width: 8px;
+}
+
+.dt2-scrollbars-horizontal-wrapper {
+ left: 3px;
+ right: 3px;
+ top: auto;
+ bottom: -12px;
+ height: 8px;
+}
+
/* Scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
- background-color: var(--color-resize);
+ background-color: var(--color-base-300);
border-radius: 3px;
pointer-events: auto; /* Allow interaction with the scrollbar */
cursor: pointer;
+ position: absolute;
+ border-radius: 3px; /* Rounded corners */
+ pointer-events: auto; /* Enable interaction */
+ cursor: pointer;
+}
+
+
+/* Vertical Scrollbar */
+.dt2-scrollbars-vertical {
+ left: 0;
+ right: 0;
+ top: auto;
+ bottom: auto;
+ width: 100%; /* Fits inside its wrapper */
+}
+
+/* Horizontal Scrollbar */
+.dt2-scrollbars-horizontal {
+ left: auto;
+ right: auto;
+ top: 0;
+ bottom: 0;
+ height: 100%; /* Fits inside its wrapper */
}
/* Scrollbar hover effects */
@@ -130,7 +127,6 @@ input:focus {
background-color: var(--color-base-content);
}
-
.dt2-table {
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
@@ -153,7 +149,6 @@ input:focus {
}
.dt2-body {
- max-height: 650px;
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
font-size: 14px;
min-width: max-content;
@@ -200,6 +195,36 @@ input:focus {
padding-right: 10px;
}
+.dt2-footer-cell {
+ cursor : pointer
+}
+
+.dt2-footer-menu {
+ position: absolute;
+ display: None;
+ z-index: var(--datagrid-menu-zindex);
+ border: 1px solid oklch(var(--b3));
+ box-sizing: border-box;
+ width: 80px;
+ background-color: var(--color-base-100); /* Add background color */
+ opacity: 1; /* Ensure full opacity */
+}
+
+.dt2-footer-menu.show {
+ display: block;
+}
+
+.dt2-footer-menu-item {
+ padding : 0 8px;
+ border-radius: 4px;
+ background-color: var(--color-base-100); /* Add background color */
+}
+
+.dt2-footer-menu-item:hover {
+ background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
+ cursor : pointer
+}
+
.dt2-resize-handle {
position: absolute;
right: 0;
@@ -212,7 +237,7 @@ input:focus {
.dt2-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
- z-index: 1;
+ z-index: var(--datagrid-resize-zindex);
display: block; /* Makes it a block element */
width: 3px;
height: 60%;
diff --git a/src/components/datagrid_new/assets/Datagrid.js b/src/components/datagrid_new/assets/Datagrid.js
index 22cd5a8..62e4e19 100644
--- a/src/components/datagrid_new/assets/Datagrid.js
+++ b/src/components/datagrid_new/assets/Datagrid.js
@@ -1,5 +1,4 @@
function bindDatagrid(datagridId, allowColumnsReordering) {
- bindTooltipsWithDelegation(datagridId);
bindScrollbars(datagridId);
makeResizable(datagridId)
}
@@ -26,16 +25,16 @@ function bindScrollbars(datagridId) {
return;
}
- let scrollingTimeout;
-
const computeScrollbarVisibility = () => {
// Determine if the content is clipped
const isVerticalRequired = body.scrollHeight > body.clientHeight;
const isHorizontalRequired = table.scrollWidth > table.clientWidth;
// Show or hide the scrollbar wrappers
- verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
- horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
+ requestAnimationFrame(() => {
+ verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
+ horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
+ });
};
const computeScrollbarSize = () => {
@@ -44,11 +43,9 @@ function bindScrollbars(datagridId) {
const totalHeight = body.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
+ let scrollbarHeight = 0;
if (totalHeight > 0) {
- const scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
- verticalScrollbar.style.height = `${scrollbarHeight}px`;
- } else {
- verticalScrollbar.style.height = `0px`;
+ scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
}
// Horizontal scrollbar width
@@ -56,15 +53,18 @@ function bindScrollbars(datagridId) {
const totalWidth = table.scrollWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
+ let scrollbarWidth = 0;
if (totalWidth > 0) {
- const scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
- horizontalScrollbar.style.width = `${scrollbarWidth}px`;
- } else {
- horizontalScrollbar.style.width = `0px`;
+ scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
}
+
+ requestAnimationFrame(() => {
+ verticalScrollbar.style.height = `${scrollbarHeight}px`;
+ horizontalScrollbar.style.width = `${scrollbarWidth}px`;
+ });
};
- const updateVerticalScrollbarPosition = () => {
+ const updateVerticalScrollbarForMouseWheel = () => {
const maxScrollTop = body.scrollHeight - body.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
@@ -75,11 +75,16 @@ function bindScrollbars(datagridId) {
};
const addDragEvent = (scrollbar, updateFunction) => {
+ if (scrollbar.dataset.dragEventsBound === "true") {
+ return; // Events are already bound to this scrollbar, so skip binding again
+ }
+
let isDragging = false;
let startY = 0;
let startX = 0;
scrollbar.addEventListener("mousedown", (e) => {
+ disableTooltip();
isDragging = true;
startY = e.clientY;
startX = e.clientX;
@@ -104,20 +109,23 @@ function bindScrollbars(datagridId) {
isDragging = false;
document.body.style.userSelect = ""; // Re-enable text selection
scrollbar.classList.remove("dt2-dragging");
+ enableTooltip();
});
+
+ // Set a flag to indicate events are bound
+ scrollbar.dataset.dragEventsBound = "true";
};
const updateVerticalScrollbar = (deltaX, deltaY) => {
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = body.scrollHeight - body.clientHeight;
+ const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
verticalScrollbar.style.top = `${newTop}px`;
-
- const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
body.scrollTop = newTop * scrollRatio;
};
@@ -125,13 +133,12 @@ function bindScrollbars(datagridId) {
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
const maxScrollLeft = table.scrollWidth - table.clientWidth;
+ const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
horizontalScrollbar.style.left = `${newLeft}px`;
-
- const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
table.scrollLeft = newLeft * scrollRatio;
};
@@ -144,7 +151,7 @@ function bindScrollbars(datagridId) {
table.scrollLeft += deltaX; // Horizontal scrolling
// Update the vertical scrollbar position
- updateVerticalScrollbarPosition();
+ updateVerticalScrollbarForMouseWheel();
// Prevent default behavior to fully manage the scroll
@@ -154,7 +161,7 @@ function bindScrollbars(datagridId) {
addDragEvent(verticalScrollbar, updateVerticalScrollbar);
addDragEvent(horizontalScrollbar, updateHorizontalScrollbar);
- body.addEventListener("wheel", handleWheelScrolling);
+ body.addEventListener("wheel", handleWheelScrolling, {passive: false});
// Initialize scrollbars
computeScrollbarVisibility();
@@ -164,7 +171,7 @@ function bindScrollbars(datagridId) {
window.addEventListener("resize", () => {
computeScrollbarVisibility();
computeScrollbarSize();
- updateVerticalScrollbarPosition();
+ updateVerticalScrollbarForMouseWheel();
});
}
@@ -229,7 +236,6 @@ function makeResizable(datagridId) {
const {colIndex, startWidth, cells} = resizingState;
const finalWidth = cells[0].offsetWidth;
- console.debug(`Column ${colIndex} resized from ${startWidth}px to ${finalWidth}px`);
// Emit custom event (server communication can be tied here)
const resizeEvent = new CustomEvent('columnResize', {
@@ -263,6 +269,9 @@ function makeResizable(datagridId) {
}
function bindColumnsSettings(datagridId) {
+ /**
+ * To change the order of the rows
+ */
console.debug("bindColumnsSettings on element " + datagridId);
const datagrid = document.querySelector(`#${datagridId}`);
if (!datagrid) {
@@ -354,23 +363,80 @@ function getColumnsDefinitions(columnsSettingsId) {
}
function getCellId(event) {
- /*
- Find the id of the dt-body-cell
- */
-
- function findParentByName(element, name) {
- let parent = element;
- while (parent) {
- if (parent.getAttribute('name') === name) {
- return parent;
- }
- parent = parent.parentElement;
- }
- return null; // Return null if no matching parent is found
+ // Find element with data-col attribute
+ const elementForColId = event.target.closest('[data-col]');
+ if (!elementForColId) {
+ return null; // Return null if we can't find a column element
+ }
+
+ const colId = elementForColId.getAttribute('data-col');
+
+ // Check if element is in header
+ const headerElement = event.target.closest('.dt2-header');
+ if (headerElement) {
+ return "header|" + colId + "|0";
}
- const parentElement = findParentByName(event.target, 'dt-body-cell')
- return parentElement ? parentElement.id : null;
+ // Try to find row ID from data-row attribute
+ const elementForRowId = event.target.closest('[data-row]');
+ if (!elementForRowId) {
+ return null; // Return null if we can't find a row element
+ }
+
+ const rowId = elementForRowId.getAttribute('data-row');
+
+ // Get parent element to determine if in body or footer
+ const elementForTablePart = elementForRowId.parentElement;
+
+ // Determine table part based on class
+ let tablePart = null;
+ if (elementForTablePart) {
+ if (elementForTablePart.classList.contains("dt2-body")) {
+ tablePart = "body";
+ } else if (elementForTablePart.classList.contains("dt2-footer")) {
+ tablePart = "footer";
+ }
+ }
+
+ // Return formatted cell identifier or null if essential data is missing
+ if (colId && rowId && tablePart) {
+ return tablePart + "|" + colId + "|" + rowId;
+ }
+
+ return null;
+}
+
+function getCellBoundaries(event) {
+ // Get the target element from the click event
+ const target = event.target;
+
+ // Find the closest parent with the class 'dt2-cell'
+ const cellElement = target.closest('.dt2-cell');
+
+ if (!cellElement) {
+ console.warn('No parent element with class "dt2-cell" found.');
+ return null;
+ }
+
+ // Get the nearest ancestor with relative/absolute positioning
+ const positionedAncestor = cellElement.offsetParent;
+
+ // Get the bounding rectangle of the cell
+ const cellRect = cellElement.getBoundingClientRect();
+
+ // Determine the ancestor's position for relative adjustment
+ const ancestorRect = positionedAncestor.getBoundingClientRect()
+
+ // Calculate x and y positions relative to the positioned ancestor
+ const x = cellRect.left - ancestorRect.left; // Relative to the ancestor
+ const y = cellRect.top - ancestorRect.top; // Relative to the ancestor
+
+ return {
+ x,
+ y,
+ height: cellRect.height,
+ width: cellRect.width,
+ };
}
function getClickModifier(event) {
@@ -381,15 +447,49 @@ function getClickModifier(event) {
const isAltGr = event.ctrlKey && event.altKey && !event.shiftKey && event.code === "AltRight";
if (!isAltGr) {
- if (event.altKey) { res += "alt-" }
- if (event.ctrlKey) { res += "ctrl-" }
+ if (event.altKey) {
+ res += "alt-"
+ }
+ if (event.ctrlKey) {
+ res += "ctrl-"
+ }
} else {
res += "altgr-"; // Special case for AltGr
}
- if (event.metaKey) { res += "meta-" }
- if (event.shiftKey) { res += "shift-" }
+ if (event.metaKey) {
+ res += "meta-"
+ }
+ if (event.shiftKey) {
+ res += "shift-"
+ }
return res;
}
return null;
+}
+
+function validateOnClickRequest(datagridId, event) {
+ if (event.target.id !== datagridId) { // only try to cancel event sent by the datagrid
+ return;
+ }
+
+ const triggeringElt = event.detail.requestConfig.triggeringEvent.target
+ const sidebar = triggeringElt.closest('.dt2-sidebar');
+ if (sidebar) {
+ event.preventDefault();
+ }
+}
+
+function onAfterSettle(datagridId, event) {
+ const datagrid = document.getElementById(datagridId);
+ if (!datagrid) {
+ console.error(`Datagrid with ID "${datagridId}" not found.`);
+ return;
+ }
+
+ // Only rebind if the table is resend
+ const response = event.detail.xhr.responseText;
+ if (response.includes("hx-on::before-settle")) {
+ bindDatagrid(datagridId)
+ }
}
\ No newline at end of file
diff --git a/src/components/datagrid_new/components/DataGrid.py b/src/components/datagrid_new/components/DataGrid.py
index 2e0a983..ca9071d 100644
--- a/src/components/datagrid_new/components/DataGrid.py
+++ b/src/components/datagrid_new/components/DataGrid.py
@@ -1,7 +1,7 @@
import copy
import logging
from io import BytesIO
-from typing import Literal
+from typing import Literal, Any
import pandas as pd
from fasthtml.components import *
@@ -15,47 +15,94 @@ from components.datagrid_new.components.FileUpload import FileUpload
from components.datagrid_new.components.FilterAll import FilterAll
from components.datagrid_new.components.Views import Views
from components.datagrid_new.components.commands import DataGridCommandManager
-from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ColumnType, FILTER_INPUT_CID, \
- ViewType
-from components.datagrid_new.settings import DataGridDatabaseManager, DataGridRowState, DataGridColumnState, \
+from components.datagrid_new.constants import *
+from components.datagrid_new.db_management import DataGridDbManager
+from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
-from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
+from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
-from core.utils import get_unique_id, make_column_id
+from core.utils import get_unique_id, make_safe_id
logger = logging.getLogger("DataGrid")
class DataGrid(BaseComponent):
- def __init__(self, session, _id: str = None, key: str = None, settings_manager=None):
+ """
+ DataGrid component that provides a rich, interactive table with various features
+ like filtering, column management, and view management.
+ """
+
+ def __init__(self, session, _id: str = None, key: Any = None, settings_manager=None, boundaries=None):
+ """
+ Initialize the DataGrid component.
+
+ Args:
+ session: The current session
+ _id: Component ID
+ key: Optional key for persistence
+ settings_manager: Optional settings manager
+ """
super().__init__(session, _id)
-
self.commands = DataGridCommandManager(self)
-
self._key = key
self._settings_manager = settings_manager
- self._db = DataGridDatabaseManager(session, settings_manager, key)
+ self._db = DataGridDbManager(session, settings_manager, key)
+
+ # Load state and data
self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe()
+ # update boundaries if possible
+ self.set_boundaries(boundaries)
+
+ # Create child components
self._file_upload = self._create_component(FileUpload, f"fu_{self._id}")
self._filter_all = self._create_component(FilterAll, f"fa_{self._id}")
self._columns_settings = self._create_component(ColumnsSettings, f"scol_{self._id}")
self._views = self._create_component(Views, f"v_{self._id}")
- # init
+ # Initial setup
self.close_sidebar()
+
+ # First time updates
+ if len(self._state.footers) == 0:
+ self._state.footers.append(DataGridFooterConf())
+
+ # ----------------------
+ # Initialization Methods
+ # ----------------------
def init_from_excel(self):
- df = pd.read_excel(BytesIO(self._file_upload.file_content),
- sheet_name=self._file_upload.selected_sheet_name)
+ """
+ Initialize the DataGrid from an Excel file uploaded by the user.
+
+ Returns:
+ self: For method chaining
+ """
+ df = pd.read_excel(
+ BytesIO(self._file_upload.file_content),
+ sheet_name=self._file_upload.selected_sheet_name
+ )
+
self._settings.file_name = self._file_upload.file_name
self._settings.selected_sheet_name = self._file_upload.selected_sheet_name
self._db.save_settings(self._settings)
- return self.init_from_dataframe(df)
+
+ return self.init_from_dataframe(df, save_state=True)
- def init_from_dataframe(self, df: DataFrame):
+ def init_from_dataframe(self, df: DataFrame, save_state=False):
+ """
+ Initialize the DataGrid from a pandas DataFrame.
+
+ Args:
+ df: Source DataFrame
+ save_state: Whether to save state to database
+
+ Returns:
+ self: For method chaining
+ """
+
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
@@ -69,14 +116,17 @@ class DataGrid(BaseComponent):
return ColumnType.Text # Default to Text if no match
self._df = df.copy()
- self._df.columns = self._df.columns.map(make_column_id) # make sure column names are trimmed
+ self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
- self._state.columns = [DataGridColumnState(make_column_id(col_id),
+ self._state.columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
- _get_column_type(self._df[make_column_id(col_id)].dtype))
+ _get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)]
- self._db.save_all(None, self._state, self._df)
+
+ if save_state:
+ self._db.save_all(None, self._state, self._df)
+
return self
def update_columns_state(self, updates: list[dict] | None = None, mode: Literal["delta", "replace"] = "delta"):
@@ -131,7 +181,7 @@ class DataGrid(BaseComponent):
new_column = False
if updates is None:
- return self.mk_table()
+ return self.mk_table_container()
if mode == "delta":
for update in updates:
@@ -156,7 +206,7 @@ class DataGrid(BaseComponent):
self._db.save_all(self._settings, self._state, self._df if new_column else None)
- return self.mk_table(), self.close_sidebar(), self._views.render_select_view(oob=True)
+ return self.mk_table_container(), self.close_sidebar(), self._views.render_select_view(oob=True)
def add_view(self, view_name, columns: list[DataGridColumnState]):
if view_name in [v.name for v in self._settings.views]:
@@ -168,7 +218,7 @@ class DataGrid(BaseComponent):
self._db.save_all(settings=self._settings, state=self._state)
- return self.mk_table()
+ return self.mk_table_container()
def update_view(self, view_name, columns: list[DataGridColumnState]):
view = self.get_view(view_name)
@@ -179,7 +229,7 @@ class DataGrid(BaseComponent):
self._db.save_settings(self._settings)
- return self.mk_table()
+ return self.mk_table_container()
def change_view(self, view_name):
@@ -189,7 +239,7 @@ class DataGrid(BaseComponent):
self._state.selected_view = view_name
- return self.mk_table()
+ return self.mk_table_container()
def filter(self, column_id: str, filtering_values: str | list[str]):
"""
@@ -204,7 +254,7 @@ class DataGrid(BaseComponent):
else:
self._state.filtered[column_id] = filtering_values
- return self.mk_table()
+ return self.mk_table_container()
def finalize_interaction(self, new_pos=None, new_input_element=None):
res = []
@@ -214,6 +264,9 @@ class DataGrid(BaseComponent):
self._state.sidebar_visible = False
res.append(self.mk_sidebar(None, self._state.sidebar_visible, oob=True))
+ # reset footer menu
+ res.append(self.mk_footer_menu(None, oob=True))
+
# manage the selection
select_manager = self.mk_selection_manager(new_pos)
res.append(select_manager)
@@ -258,10 +311,33 @@ class DataGrid(BaseComponent):
new_pos = self.navigate(None) # create the cursor if it was the first time
return self.finalize_interaction(new_pos)
- def manage_click(self, col_index, row_index, modifier=''):
+ def manage_click(self, cell_id, modifier, boundaries):
+ try:
+ source, col_id, row_index = cell_id.split("|")
+ if source == "footer":
+ return self.show_footer_menu(col_id, int(row_index), boundaries)
+ except ValueError:
+ pass
+
new_pos = self.escape()
return self.finalize_interaction(new_pos)
+ def manage_state_changed(self, state, args):
+ if state == DATAGRID_STATE_FOOTER:
+ col_id, row_index, agg_value = args.split("|")
+ footer_conf = self._state.footers[int(row_index)]
+ footer_conf.conf[col_id] = agg_value
+ self._db.save_state(self._state)
+ col_def = self._get_col_def(col_id)
+ footer_conf = self._state.footers[int(row_index)]
+ return (self.mk_table_footer_cell(col_def, int(row_index), footer_conf, True),
+ self.mk_footer_menu(None, oob=True))
+
+ return self.mk_table()
+
+ def show_footer_menu(self, col_id: str, row_index: int, boundaries: dict):
+ return self.mk_footer_menu(col_id, row_index, boundaries)
+
def get_state(self) -> DataGridState:
return self._state
@@ -271,14 +347,30 @@ class DataGrid(BaseComponent):
def get_table_id(self):
return f"t_{self._id}"
- def get_view(self, view_name: str = None) -> DatagridView | None:
+ def get_view(self, view_name: str | None = None) -> DatagridView | None:
if view_name is None:
view_name = self._state.selected_view
+ if view_name is None: # if self._state.selected_view is None
+ return None
+
try:
return next(view for view in self._settings.views if view.name == view_name)
except StopIteration:
- return None
+ raise ValueError(f"View '{view_name}' does not exist.")
+
+ def set_boundaries(self, boundaries):
+ if boundaries is not None:
+ self._state.boundaries = {CONTAINER_WIDTH: boundaries['width'],
+ CONTAINER_HEIGHT: boundaries['height']}
+
+ def mk_table_container(self):
+ return Div(
+ self.mk_scrollbars(),
+ self.mk_table(),
+ cls="dt2-container",
+ id=f"tc_{self._id}"
+ )
def mk_scrollbars(self):
return Div(
@@ -290,7 +382,7 @@ class DataGrid(BaseComponent):
def mk_table(self, oob=False):
htmx_extra_params = {
- "hx-on::after-settle": f"bindDatagrid('{self._id}', true);",
+ "hx-on::before-settle": f"onAfterSettle('{self._id}', event);",
# "hx-on::before-request": "onCellEdition(event);",
}
@@ -331,18 +423,15 @@ class DataGrid(BaseComponent):
hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
+ name="dt2-kbm",
),
- if self._df is None:
- return Div(id=f"t_{self._id}")
-
return Div(
self.mk_selection_manager(),
Div(Label(mk_icon(icon_move), cls="flex gap-2"), id=f"tdd_{self._id}", cls="dt2-drag-drop"),
- Div(id=f"tcdd_{self._id}"),
+ self.mk_footer_menu(None),
_mk_keyboard_management(),
Div(
- self.mk_scrollbars(),
self.mk_table_header(),
self.mk_table_body(),
self.mk_table_footer(),
@@ -386,6 +475,7 @@ class DataGrid(BaseComponent):
def mk_table_body(self):
df = self._get_filtered_df()
+ max_height = self._compute_body_max_height()
return Div(
*[Div(
@@ -395,34 +485,18 @@ class DataGrid(BaseComponent):
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index],
cls="dt2-body",
+ style=f"max-height:{max_height}px;",
id=f"tb_{self._id}"
)
def mk_table_footer(self):
- def _mk_footer(footer: DataGridFooterConf, col_def: DataGridColumnState):
- if not col_def.usable:
- return None
-
- if not col_def.visible:
- return Div(cls="dt2-col-hidden")
-
- if col_def.col_id in footer.conf:
- value = "Found !"
- else:
- value = "very long Footer"
-
- return Div(mk_ellipsis(value),
- data_col=col_def.col_id,
- style=f"width:{col_def.width}px;",
- cls="dt2-cell ",
- )
-
return Div(
*[Div(
- *[_mk_footer(footer, col_def) for col_def in self._state.columns],
+ *[self.mk_table_footer_cell(col_def, row_index, footer) for col_def in self._state.columns],
id=f"tf_{self._id}",
+ data_row=f"{row_index}",
cls="dt2-row dt2-row-footer",
- ) for footer in self._state.footers or [DataGridFooterConf()]],
+ ) for row_index, footer in enumerate(self._state.footers)],
cls="dt2-footer",
id=f"tf_{self._id}"
)
@@ -434,17 +508,14 @@ class DataGrid(BaseComponent):
if not col_def.visible:
return Div(cls="dt2-col-hidden")
- content, extra_cls = self.mk_body_cell_content(col_pos, row_index, col_def)
- cls_to_use = "dt2-cell" + (f" {extra_cls}" if extra_cls else "")
+ content = self.mk_body_cell_content(col_pos, row_index, col_def)
return Div(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
- cls=cls_to_use)
+ cls="dt2-cell")
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
- cls = ""
- content = ""
def mk_bool(value):
return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False),
@@ -473,17 +544,80 @@ class DataGrid(BaseComponent):
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
return tuple(res)
- if col_def.visible:
- column_type = col_def.type
-
- if column_type == ColumnType.Bool:
- content = mk_bool(self._df.iloc[row_index, col_def.col_index])
- elif column_type == ColumnType.Number:
- content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
- else:
- content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
+ column_type = col_def.type
- return content, cls
+ if column_type == ColumnType.Bool:
+ content = mk_bool(self._df.iloc[row_index, col_def.col_index])
+ elif column_type == ColumnType.Number:
+ content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
+ elif column_type == ColumnType.RowIndex:
+ content = mk_number(row_index)
+ else:
+ content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
+
+ return content
+
+ def mk_table_footer_cell(self, col_def, row_index: int, footer_conf, oob=False):
+ """
+ Generates a footer cell for a data table based on the provided column definition,
+ row index, footer configuration, and optional out-of-bound setting. This method
+ applies appropriate aggregation functions, determines visibility, and structures
+ the cell's elements accordingly.
+
+ :param col_def: Details of the column state, including its usability, visibility,
+ and column ID, which are necessary to determine how the footer
+ cell should be created.
+ :type col_def: DataGridColumnState
+ :param row_index: The specific index of the footer row where this cell will be
+ added. This parameter is used to uniquely identify the cell
+ within the footer.
+ :type row_index: int
+ :param footer_conf: Configuration for the footer that contains mapping of column
+ IDs to their corresponding aggregation functions. This is
+ critical for calculating aggregated values for the cell content.
+ :type footer_conf: DataGridFooterConf
+ :param oob: A boolean flag indicating whether the configuration involves any
+ out-of-bound parameters that must be handled specifically. This
+ parameter is optional and defaults to False.
+ :type oob: bool
+ :return: Returns an instance of `Div`, containing the visually structured footer
+ cell content, including the calculated aggregation if applicable. If
+ the column is not usable, it returns None. For non-visible columns, it
+ returns a hidden cell `Div`. The aggregation value is displayed for valid
+ aggregations. If none is applicable or the configuration is invalid,
+ appropriate default content or styling is applied.
+ :rtype: Div | None
+ """
+ if not col_def.usable:
+ return None
+
+ if not col_def.visible:
+ return Div(cls="dt2-col-hidden")
+
+ if col_def.col_id in footer_conf.conf:
+ agg_function = footer_conf.conf[col_def.col_id]
+ if agg_function == FooterAggregation.Sum.value:
+ value = self._df[col_def.col_id].sum()
+ elif agg_function == FooterAggregation.Min.value:
+ value = self._df[col_def.col_id].min()
+ elif agg_function == FooterAggregation.Max.value:
+ value = self._df[col_def.col_id].max()
+ elif agg_function == FooterAggregation.Mean.value:
+ value = self._df[col_def.col_id].mean()
+ elif agg_function == FooterAggregation.Count.value:
+ value = self._df[col_def.col_id].count()
+ else:
+ value = "** Invalid aggregation function **"
+ else:
+ value = None
+
+ return Div(mk_ellipsis(value, cls="dt2-cell-content-number"),
+ data_col=col_def.col_id,
+ style=f"width:{col_def.width}px;",
+ cls="dt2-cell dt2-footer-cell",
+ id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
+ hx_swap_oob='true' if oob else None,
+ )
def mk_menu(self, oob=False):
return Div(
@@ -550,6 +684,38 @@ class DataGrid(BaseComponent):
return select_manager
+ def mk_footer_menu(self, col_id: str | None, row_index: int = None, cell_boundaries: dict = None, oob=False):
+ def _compute_footer_menu_height(_cell_boundaries: dict):
+ y = _cell_boundaries['y'] # position of the footer cell that was clicked
+ footer_height = _cell_boundaries['height'] # position of the footer cell that was clicked
+ table_max_height = self._state.boundaries[CONTAINER_HEIGHT]
+ menu_bottom = y + footer_height + len(FooterAggregation) * 21 + 16
+ return y + footer_height \
+ if menu_bottom < table_max_height \
+ else y - footer_height - len(FooterAggregation) * 21 + 16
+
+ if col_id is None:
+ return Div(cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm ",
+ id=f"tcm_{self._id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
+ if col_id:
+ assert cell_boundaries is not None, "boundaries must be provided if col_id is provided"
+
+ menu_top = _compute_footer_menu_height(cell_boundaries)
+
+ return Div(
+ *[Div(
+ mk_ellipsis(agg.value),
+ **self.commands.update_footer_aggregation(col_id, row_index, agg.value, cls="dt2-footer-menu-item"),
+ ) for agg in FooterAggregation],
+ cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm show",
+ style=f"left:{cell_boundaries['x'] + 10}px;top:{menu_top}px;",
+ id=f"tcm_{self._id}",
+ hx_swap_oob='true' if oob else None,
+ )
+
def toggle_sidebar(self, content):
logger.debug(f"toggle sidebar {self._id}. Previous state: {self._state.sidebar_visible}")
self._state.sidebar_visible = not self._state.sidebar_visible
@@ -569,7 +735,7 @@ class DataGrid(BaseComponent):
def _get_filtered_df(self):
if self._df is None:
- return None
+ return DataFrame()
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
@@ -577,6 +743,9 @@ class DataGrid(BaseComponent):
return df
+ def _get_col_def(self, col_id: str):
+ return next((col_def for col_def in self._state.columns if col_def.col_id == col_id), None)
+
def _apply_sort(self, df):
if df is None:
return None
@@ -613,21 +782,37 @@ class DataGrid(BaseComponent):
component_type,
owner=self)
+ def _compute_body_max_height(self):
+ filter_height = 40
+ header_height = 24
+ footer_height = 24
+ h_scrollbar_height = 25
+ to_subtract = filter_height + header_height + footer_height + h_scrollbar_height + 5
+
+ max_height = self._state.boundaries.get(CONTAINER_HEIGHT, DATAGRID_MAX_HEIGHT) \
+ if self._state.boundaries \
+ else DATAGRID_MAX_HEIGHT
+
+ max_height -= to_subtract
+
+ return max_height
+
def __ft__(self):
return Div(
- mk_tooltip_container(self._id),
- Div(Div(self._filter_all, self._views, cls="flex"),
- self.mk_menu(), cls="flex justify-between"),
-
Div(
- self.mk_table(),
- self.mk_sidebar(None, self._state.sidebar_visible),
- cls="dt2-main",
+ Div(Div(self._filter_all, self._views, cls="flex"),
+ self.mk_menu(), cls="flex justify-between"),
+
+ Div(
+ self.mk_table_container(),
+ self.mk_sidebar(None, self._state.sidebar_visible),
+ cls="dt2-main",
+ ),
+
+ Script(f"bindDatagrid('{self._id}', false);"),
+ **self.commands.on_click()
),
-
- Script(f"bindDatagrid('{self._id}', false);"),
- id=f"{self._id}",
- **self.commands.on_click()
+ id=f"{self._id}"
)
@staticmethod
diff --git a/src/components/datagrid_new/components/FilterAll.py b/src/components/datagrid_new/components/FilterAll.py
index ae5e9da..e76465e 100644
--- a/src/components/datagrid_new/components/FilterAll.py
+++ b/src/components/datagrid_new/components/FilterAll.py
@@ -4,6 +4,7 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
+from components.datagrid_new.components.commands import FilterAllCommands
from components.datagrid_new.constants import Routes, ROUTE_ROOT, FILTER_INPUT_CID, DATAGRID_INSTANCE_ID
from core.utils import get_unique_id
@@ -21,6 +22,7 @@ class FilterAll(BaseComponent, ):
:param datagrid:
"""
super().__init__(session, _id)
+ self.commands = FilterAllCommands(self)
self._owner = owner
logger.debug(f"FilterAll component created with id: {self._id}")
@@ -46,11 +48,13 @@ class FilterAll(BaseComponent, ):
Input(name='f',
placeholder="Filter...",
value=value,
+ #**self.commands.filter_all(),
hx_post=f"{ROUTE_ROOT}{Routes.Filter}",
- hx_trigger="keyup changed throttle:300ms",
+ hx_trigger="keyup",
hx_target=f"#t_{self._owner.get_id()}",
hx_swap="outerHTML",
- hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
+ hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
+ ),
cls="input input-sm flex gap-2"
),
id=f"fi_{self._id}", # fa stands for 'filter all'
@@ -59,12 +63,17 @@ class FilterAll(BaseComponent, ):
def _mk_reset_button(self):
return Div(icon_dismiss_regular,
- cls="icon-24 my-auto icon-btn ml-2",
- hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
- hx_trigger="click",
- hx_target=f"#t_{self._owner.get_id()}",
- hx_swap="outerHTML",
- hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
+ #cls="icon-24 my-auto icon-btn ml-2",
+ **self.commands.reset_filter_all(cls="icon-24 my-auto icon-btn ml-2"),
+ # hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
+ # hx_trigger="click",
+ # hx_target=f"#t_{self._owner.get_id()}",
+ # hx_swap="outerHTML",
+ # hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
+ ),
+
+ def get_datagrid(self):
+ return self._owner
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
diff --git a/src/components/datagrid_new/components/commands.py b/src/components/datagrid_new/components/commands.py
index 9136af2..1cf010c 100644
--- a/src/components/datagrid_new/components/commands.py
+++ b/src/components/datagrid_new/components/commands.py
@@ -1,12 +1,12 @@
import json
-from components.datagrid_new.constants import ROUTE_ROOT, Routes
+from components.BaseCommandManager import BaseCommandManager
+from components.datagrid_new.constants import ROUTE_ROOT, Routes, DATAGRID_STATE_FOOTER, FILTER_INPUT_CID
-class DataGridCommandManager:
- def __init__(self, datagrid):
- self.datagrid = datagrid
- self._id = self.datagrid.get_id()
+class DataGridCommandManager(BaseCommandManager):
+ def __init__(self, owner):
+ super().__init__(owner)
def cancel(self):
return {
@@ -53,7 +53,7 @@ class DataGridCommandManager:
def update_columns_settings(self, component):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
- "hx-target": f"#{self.datagrid.get_table_id()}", # table
+ "hx-target": f"#{self._owner.get_table_id()}", # table
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "updates": getColumnsDefinitions("{component.get_id()}")}}',
}
@@ -64,21 +64,37 @@ class DataGridCommandManager:
def show_columns(self, col_defs: list, cls=""):
return self._get_hide_show_columns_attrs("Show", col_defs, "true", cls=cls)
- def reset_filters(self, cls=""):
- return {"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
- "hx_vals": f'{{"g_id": "{self._id}"}}',
- "hx_target": f"#t_{self._id}",
- "hx_swap": "outerHTML",
- "data_tooltip": "Reset all filters",
- "cls": self.merge_class(cls, "dt-tooltip")}
+ def show_footer_menu(self, col_id, cls=""):
+ return {
+ "hx_post": f"{ROUTE_ROOT}{Routes.ShowFooterMenu}",
+ "hx_target": f"#tcm_{self._id}",
+ "hx_swap": "outerHTML",
+ "hx-trigger": "click consume",
+ "hx_vals": f'js:{{"_id": "{self._id}", "col_id": "{col_id}", "boundaries": getCellBoundaries(event)}}',
+ "data_tooltip": "Change aggregation",
+ "cls": self.merge_class(cls, "mmt-tooltip")
+ }
+
+ def update_footer_aggregation(self, col_id, row_index, agg_type, cls=""):
+ args = f"{col_id}|{row_index}|{agg_type}"
+ return {
+ "hx_post": f"{ROUTE_ROOT}{Routes.UpdateState}",
+ "hx_target": f"#t_{self._id}",
+ "hx_swap": "none",
+ "hx_vals": f'{{"_id": "{self._id}", "state": "{DATAGRID_STATE_FOOTER}", "args": "{args}"}}',
+ "hx-trigger": "click consume",
+ # "data_tooltip": f"Change footer aggregation to {agg_type}",
+ "cls": self.merge_class(cls, "mmt-tooltip")
+ }
def on_click(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
"hx-target": f"#tsm_{self._id}",
- "hx-trigger"
+ "hx-trigger" : "click",
"hx-swap": "outerHTML",
- "hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event)}}',
+ "hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}',
+ "hx-on::before-request": f'validateOnClickRequest("{self._id}", event)',
}
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
@@ -93,35 +109,60 @@ class DataGridCommandManager:
"data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "mmt-tooltip")
}
-
- @staticmethod
- def merge(*items):
- """
- Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
- If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
- If the key's value is not a string, an error is raised.
+ #
+ # @staticmethod
+ # def merge(*items):
+ # """
+ # Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
+ # If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
+ # If the key's value is not a string, an error is raised.
+ #
+ # :param items: dictionaries to be merged. If all items are None, None is returned.
+ # :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
+ # :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
+ # """
+ # if all(item is None for item in items):
+ # return None
+ #
+ # res = {}
+ # for item in [item for item in items if item is not None]:
+ #
+ # for key, value in item.items():
+ # if not key in res:
+ # res[key] = value
+ # else:
+ # if isinstance(res[key], str):
+ # res[key] += " " + value
+ # else:
+ # raise NotImplementedError("")
+ #
+ # return res
+ #
+ # @staticmethod
+ # def merge_class(cls1, cls2):
+ # return (cls1 + " " + cls2) if cls2 else cls1
- :param items: dictionaries to be merged. If all items are None, None is returned.
- :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
- :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
- """
- if all(item is None for item in items):
- return None
-
- res = {}
- for item in [item for item in items if item is not None]:
-
- for key, value in item.items():
- if not key in res:
- res[key] = value
- else:
- if isinstance(res[key], str):
- res[key] += " " + value
- else:
- raise NotImplementedError("")
-
- return res
+
+class FilterAllCommands(BaseCommandManager):
+ def __init__(self, owner):
+ super().__init__(owner)
- @staticmethod
- def merge_class(cls1, cls2):
- return (cls1 + " " + cls2) if cls2 else cls1
+ def filter_all(self):
+ return {
+ "hx_post": f"{ROUTE_ROOT}{Routes.Filter}",
+ "hx_trigger": "keyup changed consume",
+ "hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
+ "hx_swap": "outerHTML",
+ "hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
+ }
+
+ def reset_filter_all(self, cls=""):
+ return {
+ "hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
+ "hx_trigger": "click consume",
+ "hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
+ "hx_swap": "outerHTML",
+ "hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
+ "data_tooltip": "Reset filter",
+ "cls": self.merge_class(cls, "mmt-tooltip"),
+ }
\ No newline at end of file
diff --git a/src/components/datagrid_new/constants.py b/src/components/datagrid_new/constants.py
index e33c47e..e6d8ad7 100644
--- a/src/components/datagrid_new/constants.py
+++ b/src/components/datagrid_new/constants.py
@@ -6,6 +6,18 @@ FILTER_INPUT_CID = "__filter_input__"
DEFAULT_COLUMN_WIDTH = 100
ADD_NEW_VIEW = "__add_new_view__"
+DATAGRID_DB_ENTRY = "Datagrid"
+DATAGRID_DB_SETTINGS_ENTRY = "Settings"
+DATAGRID_DB_STATE_ENTRY = "State"
+DATAGRID_DB_DATAFRAME_ENTRY = "Dataframe"
+
+DATAGRID_MAX_HEIGHT = 300
+CONTAINER_WIDTH = "container_width"
+CONTAINER_HEIGHT = "container_height"
+
+DATAGRID_STATE_FOOTER = "footer"
+
+
class Routes:
Filter = "/filter" # request the filtering in the grid
ResetFilter = "/reset_filter" #
@@ -19,6 +31,8 @@ class Routes:
ChangeView = "/change_view"
AddView = "/add_view"
UpdateView = "/update_view"
+ ShowFooterMenu = "/show_footer_menu"
+ UpdateState = "/update_state"
class ColumnType(Enum):
@@ -34,3 +48,15 @@ class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"
+
+class FooterAggregation(Enum):
+ Sum = "Sum"
+ Mean = "Mean"
+ Min = "Min"
+ Max = "Max"
+ Count = "Count"
+ FilteredSum = "FilteredSum"
+ FilteredMean = "FilteredMean"
+ FilteredMin = "FilteredMin"
+ FilteredMax = "FilteredMax"
+ FilteredCount = "FilteredCount"
\ No newline at end of file
diff --git a/src/components/datagrid_new/db_management.py b/src/components/datagrid_new/db_management.py
new file mode 100644
index 0000000..597bc5d
--- /dev/null
+++ b/src/components/datagrid_new/db_management.py
@@ -0,0 +1,111 @@
+from pandas import DataFrame
+
+from components.datagrid_new.constants import DATAGRID_DB_ENTRY, DATAGRID_DB_SETTINGS_ENTRY, DATAGRID_DB_STATE_ENTRY, \
+ DATAGRID_DB_DATAFRAME_ENTRY
+from components.datagrid_new.settings import DataGridSettings, DataGridState
+from core.settings_management import SettingsManager
+from core.utils import make_safe_id
+
+
+class DataframeWrapper:
+ def __init__(self, df: DataFrame):
+ self.df = df
+
+ def __eq__(self, other):
+ if isinstance(other, DataframeWrapper):
+ return self.df.equals(other.df)
+ else:
+ return False
+
+ def __hash__(self):
+ return hash(self.df.to_json())
+
+ @staticmethod
+ def use_refs():
+ return {"df"}
+
+
+class DataGridDbManager:
+ def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
+ self._session = session
+ self._settings_manager = settings_manager
+ self._key = "#".join(make_safe_id(item) for item in key) if key else ""
+
+ # init the db if needed
+ if self._settings_manager and not self._settings_manager.exists(self._session, self._get_db_entry()):
+ self._settings_manager.save(self._session, self._get_db_entry(), {})
+
+ def _get_db_entry(self):
+ return f"{DATAGRID_DB_ENTRY}_{self._key}"
+
+ def save_settings(self, settings: DataGridSettings):
+ if self._settings_manager is None:
+ return
+
+ self._settings_manager.put(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_SETTINGS_ENTRY,
+ settings)
+
+ def save_state(self, state: DataGridState):
+ if self._settings_manager is None:
+ return
+
+ self._settings_manager.put(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_STATE_ENTRY,
+ state)
+
+ def save_dataframe(self, df: DataFrame):
+ if self._settings_manager is None:
+ return
+
+ self._settings_manager.put(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_DATAFRAME_ENTRY,
+ DataframeWrapper(df))
+
+ def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
+ if self._settings_manager is None:
+ return
+
+ items = {}
+ if settings is not None:
+ items[DATAGRID_DB_SETTINGS_ENTRY] = settings
+ if state is not None:
+ items[DATAGRID_DB_STATE_ENTRY] = state
+ if df is not None:
+ items[DATAGRID_DB_DATAFRAME_ENTRY] = DataframeWrapper(df)
+
+ self._settings_manager.put_many(self._session, self._get_db_entry(), items)
+
+ def load_settings(self):
+ if self._settings_manager is None:
+ return DataGridSettings()
+
+ return self._settings_manager.get(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_SETTINGS_ENTRY,
+ default=DataGridSettings())
+
+ def load_state(self):
+ if self._settings_manager is None:
+ return DataGridState()
+
+ return self._settings_manager.get(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_STATE_ENTRY,
+ default=DataGridState())
+
+ def load_dataframe(self):
+ if self._settings_manager is None:
+ return None
+
+ wrapper = self._settings_manager.get(self._session,
+ self._get_db_entry(),
+ DATAGRID_DB_DATAFRAME_ENTRY,
+ default=None)
+ if wrapper is None:
+ return None
+
+ return wrapper.df
diff --git a/src/components/datagrid_new/settings.py b/src/components/datagrid_new/settings.py
index 5c483b1..2b56cb4 100644
--- a/src/components/datagrid_new/settings.py
+++ b/src/components/datagrid_new/settings.py
@@ -1,15 +1,6 @@
import dataclasses
-import json
-from io import StringIO
-import pandas as pd
-from pandas import DataFrame
-
-from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType, ROUTE_ROOT, Routes
-from core.settings_management import SettingsManager, SettingsTransaction
-from core.utils import make_column_id
-
-DATAGRID_SETTINGS_ENTRY = "DatagridSettings"
+from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
@dataclasses.dataclass
@@ -48,7 +39,7 @@ class DatagridSelectionState:
@dataclasses.dataclass
class DataGridFooterConf:
- conf: dict[str, str] = dataclasses.field(default_factory=dict)
+ conf: dict[str, str] = dataclasses.field(default_factory=dict) # first 'str' is the column id
@dataclasses.dataclass
@@ -64,65 +55,25 @@ class DataGridSettings:
selected_sheet_name: str = None
header_visible: bool = True
views: list[DatagridView] = dataclasses.field(default_factory=list)
+
+ @staticmethod
+ def use_refs():
+ return {"views"}
@dataclasses.dataclass
class DataGridState:
sidebar_visible: bool = False
selected_view: str = None
- columns: list[DataGridColumnState] = None
- rows: list[DataGridRowState] = None # only the rows that have a specific state
+ columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
+ rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)
sorted: list = dataclasses.field(default_factory=list)
filtered: dict = dataclasses.field(default_factory=dict)
edition: DatagridEditionState = dataclasses.field(default_factory=DatagridEditionState)
selection: DatagridSelectionState = dataclasses.field(default_factory=DatagridSelectionState)
-
-
-class DataGridDatabaseManager:
- def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
- self._session = session
- self._settings_manager = settings_manager
- self._key = "#".join(make_column_id(item) for item in key)
+ boundaries: dict = None # {height; width} that gives the maximum space possible
- def save_settings(self, settings: DataGridSettings):
- self._settings_manager.put(self._session, self.get_settings_entry(), settings)
-
- def save_state(self, state: DataGridState):
- self._settings_manager.put(self._session, self.get_state_entry(), state)
-
- def save_dataframe(self, df: DataFrame):
- self._settings_manager.put(self._session, self.get_data_entry(), df.to_json())
-
- def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
- with SettingsTransaction(self._session, self._settings_manager) as st:
- if settings is not None:
- st.put(self.get_settings_entry(), settings)
- if state is not None:
- st.put(self.get_state_entry(), state)
- if df is not None:
- st.put(self.get_data_entry(), df.to_json())
-
- def load_settings(self):
- return self._settings_manager.get(self._session, self.get_settings_entry(), default=DataGridSettings())
-
- def load_state(self):
- return self._settings_manager.get(self._session, self.get_state_entry(), default=DataGridState())
-
- def load_dataframe(self):
- as_json = self._settings_manager.get(self._session, self.get_data_entry(), default=None)
- if as_json is None:
- return None
-
- df = pd.read_json(StringIO(as_json))
- return df
-
- def get_settings_entry(self):
- return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_settings"
-
- def get_state_entry(self):
- return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_state"
-
- def get_data_entry(self):
- return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_data"
-
+ @staticmethod
+ def use_refs():
+ return {"columns", "rows", "footers"}
diff --git a/src/components/debugger/DebuggerApp.py b/src/components/debugger/DebuggerApp.py
index 54b7de6..16afa2d 100644
--- a/src/components/debugger/DebuggerApp.py
+++ b/src/components/debugger/DebuggerApp.py
@@ -3,15 +3,30 @@ import logging
from fasthtml.fastapp import fast_app
from components.debugger.constants import Routes
-from core.instance_manager import InstanceManager
+from core.instance_manager import InstanceManager, debug_session
debugger_app, rt = fast_app()
logger = logging.getLogger("Debugger")
-@rt(Routes.DbEngine)
-def post(session, _id: str, digest: str = None):
- logger.debug(f"Entering {Routes.DbEngine} with args {_id=}, {digest=}")
+@rt(Routes.DbEngineData)
+def post(session, _id: str, user_id: str, digest: str = None):
+ logger.debug(f"Entering {Routes.DbEngineData} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
- return instance.add_tab(digest)
+ return instance.add_tab(user_id, digest)
+
+
+@rt(Routes.JsonViewerFold)
+def post(session, _id: str, node_id: str, folding: str):
+ logger.debug(f"Entering {Routes.JsonViewerFold} 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)
+
+@rt(Routes.JsonOpenDigest)
+def post(session, _id: str, user_id: str, digest: str):
+ logger.debug(f"Entering {Routes.JsonOpenDigest} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
+ instance = InstanceManager.get(session, _id)
+ return instance.open_digest(user_id, digest)
+
\ No newline at end of file
diff --git a/src/components/debugger/assets/Debugger.css b/src/components/debugger/assets/Debugger.css
new file mode 100644
index 0000000..00a9788
--- /dev/null
+++ b/src/components/debugger/assets/Debugger.css
@@ -0,0 +1,72 @@
+:root:has(input.theme-controller[value=light]:checked),
+[data-theme="light"] {
+ --json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
+ --json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
+ --json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
+ --json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
+ --json-null: var(--color-base-content);
+ --json-digest: var(--color-base-content);
+}
+
+:root:has(input.theme-controller[value=dark]:checked),
+[data-theme="dark"] {
+ --json-bool: oklch(88.5% 0.062 18.334); /* tailwindcss orange-200 */
+ --json-string: oklch(92.5% 0.084 155.995); /* tailwindcss green-200 */
+ --json-number: oklch(88.2% 0.059 254.128); /* tailwindcss blue-200 */
+ --json-object: oklch(44.4% 0.177 26.899); /* tailwindcss red-800 */
+ --json-null: var(--color-base-content);
+ --json-digest: var(--color-base-content);
+}
+
+
+:root:has(input.theme-controller[value=cupcake]:checked),
+[data-theme="cupcake"] {
+ --json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
+ --json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
+ --json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
+ --json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
+ --json-null: var(--color-base-content); /* tailwindcss violet-400 */
+ --json-digest: var(--color-base-content);
+}
+
+
+.mmt-jsonviewer {
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+ font-size: 14px;
+}
+
+/* Use inherited CSS variables for your custom theme */
+.mmt-jsonviewer-bool {
+ color: var(--json-bool);
+}
+
+.mmt-jsonviewer-string {
+ color: var(--json-string);
+}
+
+.mmt-jsonviewer-number {
+ color: var(--json-number);
+}
+
+.mmt-jsonviewer-null {
+ color: var(--json-null);
+}
+
+.mmt-jsonviewer-digest {
+ color: var(--json-digest);
+ cursor: pointer;
+
+}
+
+.mmt-jsonviewer-object {
+ color: var(--json-object);
+}
+
+/*:root:has(input.theme-controller[value=dark]:checked),*/
+/*[data-theme="dark"] {*/
+/* --json-bool: oklch(40.8% 0.123 38.172); !* tailwindcss orange-900 *!*/
+/* --json-string: oklch(39.3% 0.095 152.535); !* tailwindcss green-900 *!*/
+/* --json-number: oklch(37.9% 0.146 265.522); !* tailwindcss blue-900 *!*/
+/* --json-null: var(--color-base-content);*/
+/* --json-digest: var(--color-base-content);*/
+/*}*/
diff --git a/src/components/debugger/assets/Debugger.js b/src/components/debugger/assets/Debugger.js
index e56cc70..abfccdb 100644
--- a/src/components/debugger/assets/Debugger.js
+++ b/src/components/debugger/assets/Debugger.js
@@ -1,5 +1,5 @@
// Import the svelte-jsoneditor module
-import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
+// import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
/**
* Initializes and displays a JSON editor using the Svelte JSON Editor.
diff --git a/src/components/debugger/assets/icons.py b/src/components/debugger/assets/icons.py
index 5743071..49b7f1e 100644
--- a/src/components/debugger/assets/icons.py
+++ b/src/components/debugger/assets/icons.py
@@ -7,3 +7,33 @@ icon_dbengine = NotStr("""""")
+
+# Fluent CaretDown20Filled
+icon_expanded = NotStr("""""")
+
+icon_class = NotStr("""
+
+
+
+"""
+ )
diff --git a/src/components/debugger/commands.py b/src/components/debugger/commands.py
index a716c1f..8b64625 100644
--- a/src/components/debugger/commands.py
+++ b/src/components/debugger/commands.py
@@ -6,11 +6,40 @@ class Commands:
self._owner = owner
self._id = owner.get_id()
-
- def show_dbengine(self):
+ def db_engine_data(self, user_id: str):
return {
- "hx-post": f"{ROUTE_ROOT}{Routes.DbEngine}",
+ "hx-post": f"{ROUTE_ROOT}{Routes.DbEngineData}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
- "hx-vals": f'{{"_id": "{self._id}"}}',
- }
\ No newline at end of file
+ "hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}"}}',
+ }
+
+ def db_engine_refs(self, ref_id: str | None):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.DbEngineRefs}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "ref_id": "{ref_id}"}}',
+ }
+
+
+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.JsonViewerFold}",
+ "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.JsonOpenDigest}",
+ "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}"}}',
+ }
diff --git a/src/components/debugger/components/Debugger.py b/src/components/debugger/components/Debugger.py
index 9c18a2d..a6a0120 100644
--- a/src/components/debugger/components/Debugger.py
+++ b/src/components/debugger/components/Debugger.py
@@ -1,4 +1,3 @@
-import json
import logging
from fasthtml.components import *
@@ -6,13 +5,15 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.debugger.assets.icons import icon_dbengine
from components.debugger.commands import Commands
-from components.debugger.components.DbEngineDebugger import DbEngineDebugger
+from components.debugger.components.JsonViewer import JsonViewer
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
from components_helpers import mk_ellipsis, mk_icon
+from core.instance_manager import InstanceManager
from core.utils import get_unique_id
logger = logging.getLogger("Debugger")
+
class Debugger(BaseComponent):
def __init__(self, session, _id, settings_manager, tabs_manager):
super().__init__(session, _id)
@@ -21,26 +22,51 @@ class Debugger(BaseComponent):
self.tabs_manager = tabs_manager
self.commands = Commands(self)
- def add_tab(self, digest):
- content = self.mk_db_engine(digest)
+ def add_tab(self, user_id, digest):
+ content = self.mk_db_engine_object(user_id, digest)
tab_key = f"debugger-dbengine-{digest}"
title = f"DBEngine-{digest if digest else 'head'}"
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
- def mk_db_engine(self, digest):
- data = self.db_engine.debug_load(digest) if digest else self.db_engine.debug_head()
+ def mk_db_engine_object(self, user_id, digest):
+ data = self.db_engine.debug_load(user_id, digest) if digest else self.db_engine.debug_head(user_id)
+
logger.debug(f"mk_db_engine: {data}")
- return DbEngineDebugger(self._session, self._id, self, json.dumps(data))
+ return InstanceManager.get(self._session,
+ JsonViewer.create_component_id(self._session, prefix=self._id),
+ JsonViewer,
+ owner=self,
+ user_id=user_id,
+ data=data)
+
+ def mk_db_engine(self, selected):
+ return Div(
+ Input(type="radio",
+ name=f"dbengine-accordion-{self._id}",
+ checked="checked" if selected else None,
+ cls="p-0! min-h-0!",
+ ),
+ Div(
+ mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine", cls="text-sm"),
+ cls="collapse-title p-0 min-h-0 flex truncate",
+ ),
+ Div(
+ *[Div(user_id, **self.commands.db_engine_data(user_id)) for user_id in self.db_engine.debug_users()],
+ Div("refs", **self.commands.db_engine_refs(None)),
+ cls="collapse-content pr-0! truncate",
+ ),
+ cls="collapse mb-2",
+ id=f"db_engine_{self._id}",
+ )
def __ft__(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
Div(
- mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine"),
+ self.mk_db_engine(True),
cls="flex truncate",
- **self.commands.show_dbengine(),
),
id=self._id,
diff --git a/src/components/debugger/components/JsonViewer.py b/src/components/debugger/components/JsonViewer.py
new file mode 100644
index 0000000..de9ef16
--- /dev/null
+++ b/src/components/debugger/components/JsonViewer.py
@@ -0,0 +1,286 @@
+import dataclasses
+from typing import Any
+
+from fasthtml.components import *
+from pandas import DataFrame
+
+from components.BaseComponent import BaseComponent
+from components.datagrid_new.components.DataGrid import DataGrid
+from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
+from components.debugger.commands import JsonViewerCommands
+from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
+from core.serializer import TAG_OBJECT
+from core.utils import get_unique_id
+
+
+class FoldingMode:
+ COLLAPSE = "collapse"
+ EXPAND = "expand"
+
+
+@dataclasses.dataclass
+class Node:
+ value: Any
+
+
+@dataclasses.dataclass
+class ValueNode(Node):
+ hint: str = None
+
+
+@dataclasses.dataclass
+class ListNode(Node):
+ node_id: str
+ level: int
+ children: list[Node] = dataclasses.field(default_factory=list)
+
+
+@dataclasses.dataclass
+class DictNode(Node):
+ node_id: str
+ level: int
+ children: dict[str, Node] = dataclasses.field(default_factory=dict)
+
+
+class JsonViewer(BaseComponent):
+ def __init__(self, session, _id, owner, user_id, data):
+ super().__init__(session, _id)
+ self._owner = owner # debugger component
+ self.user_id = user_id
+ self.data = data
+ self._node_id = -1
+ 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)
+
+ 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 get_owner(self):
+ return self._owner
+
+ def open_digest(self, user_id: str, digest: str):
+ return self._owner.add_tab(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 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):
+ if not isinstance(node, (ListNode, DictNode)):
+ return None
+
+ must_expand = self._must_expand(node)
+
+ 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, node):
+ def _is_sha256(_value):
+ return isinstance(_value, str) and len(_value) == 64 and all(
+ c in '0123456789abcdefABCDEF' for c in _value)
+
+ if isinstance(node, DictNode):
+ return self._render_dict(node)
+ elif isinstance(node, ListNode):
+ return self._render_list(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 _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, node: DictNode):
+ if self._must_expand(node):
+ return Span("{",
+ *[
+ self._render_node(key, value)
+ for key, value in node.children.items()
+ ],
+ Div("}"),
+ id=node.node_id)
+ else:
+ return Span("{...}", id=node.node_id)
+
+ def _render_list(self, node: ListNode):
+ def _all_the_same(_node):
+ if len(_node.children) == 0:
+ return False
+
+ sample_value = _node.children[0].value
+
+ if sample_value is None:
+ return False
+
+ type_ = type(sample_value)
+ if type_ in (int, float, str, bool, list, dict, ValueNode):
+ 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("]"),
+ )
+
+ if self._must_expand(node):
+ if _all_the_same(node):
+ return _render_as_grid(node)
+ return _render_as_list(node)
+ else:
+ return Span("[...]", id=node.node_id)
+
+ def _render_node(self, key, node):
+ return Div(
+ self._mk_folding(node),
+ Span(f'{key} : ') if key is not None else None,
+ self._render_value(node),
+ 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"),
+ cls="mmt-jsonviewer",
+ id=f"{self._id}")
+
+ @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}"'
+
+ @staticmethod
+ def create_component_id(session, prefix=None, suffix=None):
+ if suffix is None:
+ suffix = get_unique_id()
+
+ return f"{prefix}{suffix}"
diff --git a/src/components/debugger/constants.py b/src/components/debugger/constants.py
index 6dc29ce..5774f1f 100644
--- a/src/components/debugger/constants.py
+++ b/src/components/debugger/constants.py
@@ -1,6 +1,14 @@
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
ROUTE_ROOT = "/debugger"
+INDENT_SIZE = 20
+MAX_TEXT_LENGTH = 50
+
+NODE_OBJECT = "Object"
+NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
+
class Routes:
- DbEngine = "/dbengine" # request the filtering in the grid
-
\ No newline at end of file
+ DbEngineData = "/dbengine-data"
+ DbEngineRefs = "/dbengine-refs"
+ JsonViewerFold = "/jsonviewer-fold"
+ JsonOpenDigest = "/jsonviewer-open-digest"
diff --git a/src/components/drawerlayout/assets/DrawerLayout.css b/src/components/drawerlayout/assets/DrawerLayout.css
index 34a6d80..84936c9 100644
--- a/src/components/drawerlayout/assets/DrawerLayout.css
+++ b/src/components/drawerlayout/assets/DrawerLayout.css
@@ -12,22 +12,35 @@ main {
}
.dl-main {
- flex-grow: 1; /* Ensures it grows to fill available space */
- height: 100%; /* Inherit height from its parent */
- overflow-x: auto;
+ display: flex;
+ flex-direction: column; /* Stack children vertically */
+ flex-grow: 1;
+ height: 100%;
+ overflow-x: auto;
+
}
+.dl-main label {
+ align-self: flex-start; /* Aligns the label to the left */
+}
+
+
.dl-main:focus {
- outline: none;
+ outline: none;
}
+.dl-page {
+ flex: 1;
+}
+
+
.dl-sidebar {
- position: relative;
- width: 150px;
- flex-shrink: 0; /* Prevent sidebar from shrinking */
- flex-grow: 0; /* Disable growth (optional for better control) */
- transition: width 0.2s ease;
- height: 100%; /* Makes the sidebar height span the entire viewport */
+ position: relative;
+ width: 150px;
+ flex-shrink: 0; /* Prevent sidebar from shrinking */
+ flex-grow: 0; /* Disable growth (optional for better control) */
+ transition: width 0.2s ease;
+ height: 100%; /* Makes the sidebar height span the entire viewport */
}
.dl-sidebar.collapsed {
diff --git a/src/components/drawerlayout/components/DrawerLayout.py b/src/components/drawerlayout/components/DrawerLayout.py
index 88679ab..df32445 100644
--- a/src/components/drawerlayout/components/DrawerLayout.py
+++ b/src/components/drawerlayout/components/DrawerLayout.py
@@ -3,10 +3,10 @@ from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.addstuff.components.AddStuffMenu import AddStuffMenu
-from components.addstuff.components.Repositories import Repositories
from components.debugger.components.Debugger import Debugger
from components.drawerlayout.assets.icons import icon_panel_contract_regular, icon_panel_expand_regular
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
+from components.repositories.components.Repositories import Repositories
from components.tabs.components.MyTabs import MyTabs
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
@@ -18,22 +18,11 @@ class DrawerLayout(BaseComponent):
_id: str = None,
settings_manager: SettingsManager = None):
super().__init__(session, _id)
+ self._settings_manager = settings_manager
self._tabs = InstanceManager.get(session, MyTabs.create_component_id(session), MyTabs)
- self._add_stuff = InstanceManager.get(session,
- AddStuffMenu.create_component_id(session),
- AddStuffMenu,
- settings_manager=settings_manager,
- tabs_manager=self._tabs)
- self._repositories = InstanceManager.get(session,
- Repositories.create_component_id(session),
- Repositories,
- settings_manager=settings_manager,
- tabs_manager=self._tabs)
- self._debugger = InstanceManager.get(session,
- Debugger.create_component_id(session),
- Debugger,
- settings_manager=settings_manager,
- tabs_manager=self._tabs)
+ self._repositories = self._create_component(Repositories)
+ self._debugger = self._create_component(Debugger)
+ self._add_stuff = self._create_component(AddStuffMenu)
def __ft__(self):
return Div(
@@ -57,13 +46,21 @@ class DrawerLayout(BaseComponent):
cls="swap",
),
- Div(self._tabs, id=f"page_{self._id}", name="page"),
+ Div(self._tabs, id=f"page_{self._id}", name="page", cls='dl-page'),
cls='dl-main',
tabindex="0",
),
cls="dl-container flex"
), Script(f"bindDrawerLayout('{self._id}')")
+ def _create_component(self, component_type: type):
+ safe_create_component_id = getattr(component_type, "create_component_id")
+ return InstanceManager.get(self._session,
+ safe_create_component_id(self._session),
+ component_type,
+ settings_manager=self._settings_manager,
+ tabs_manager=self._tabs)
+
@staticmethod
def create_component_id(session, suffix: str = ""):
return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"
diff --git a/src/components/login/LoginApp.py b/src/components/login/LoginApp.py
index ecefad9..b833693 100644
--- a/src/components/login/LoginApp.py
+++ b/src/components/login/LoginApp.py
@@ -54,10 +54,5 @@ def post(session, email: str, password: str):
# Log in user by setting session data
AuthManager.login_user(session, user_data)
- # Make sure that the settings are created for this user
- user_id = user_data["id"]
- user_email = user_data["email"]
- instance.init_user(user_id, user_email)
-
# Redirect to home page
return RedirectResponse('/', status_code=303)
diff --git a/src/components/login/components/Login.py b/src/components/login/components/Login.py
index f3d414a..562bf34 100644
--- a/src/components/login/components/Login.py
+++ b/src/components/login/components/Login.py
@@ -26,9 +26,6 @@ class Login:
self.error_message = error_message
return self.__ft__()
- def init_user(self, user_id: str, user_email: str):
- return self.settings_manager.init_user(user_id, user_email)
-
def __ft__(self):
# Create alert for error or success message
message_alert = None
diff --git a/src/components/page_layout_new.py b/src/components/page_layout_new.py
index ec4b9b9..aa5ff8a 100644
--- a/src/components/page_layout_new.py
+++ b/src/components/page_layout_new.py
@@ -1,8 +1,10 @@
from fasthtml.components import *
+from fasthtml.xtend import Script
import config
from components.footer import footer
from components.header.components.MyHeader import MyHeader
+from components_helpers import mk_tooltip_container
def page_layout_new(session, settings_manager, content):
@@ -24,9 +26,12 @@ def page_layout_lite(session, settings_manager, content):
return (
Title(f"{config.APP_NAME}"),
Div(
+ mk_tooltip_container("mmt-app"),
MyHeader(session, settings_manager),
Main(content, cls="flex-grow"),
footer(),
- cls="flex flex-col min-h-screen"
+ Script("bindTooltipsWithDelegation();"),
+ cls="flex flex-col min-h-screen",
+ id="mmt-app",
)
)
diff --git a/src/components/addstuff/AddStuffApp.py b/src/components/repositories/RepositoriesApp.py
similarity index 67%
rename from src/components/addstuff/AddStuffApp.py
rename to src/components/repositories/RepositoriesApp.py
index cce937c..e146657 100644
--- a/src/components/addstuff/AddStuffApp.py
+++ b/src/components/repositories/RepositoriesApp.py
@@ -1,14 +1,15 @@
+import json
import logging
from fasthtml.fastapp import fast_app
-from components.addstuff.components.Repositories import Repositories
-from components.addstuff.constants import Routes
+from components.repositories.components.Repositories import Repositories
+from components.repositories.constants import Routes
from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("AddStuffApp")
-add_stuff_app, rt = fast_app()
+repositories_app, rt = fast_app()
@rt(Routes.AddRepository)
@@ -19,11 +20,11 @@ def get(session):
@rt(Routes.AddRepository)
-def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str):
+def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries:str):
logger.debug(
- f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}")
+ f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) # Repository
- return instance.add_new_repository(tab_id, form_id, repository, table)
+ return instance.add_new_repository(tab_id, form_id, repository, table, json.loads(tab_boundaries))
@rt(Routes.AddTable)
@@ -33,9 +34,10 @@ def get(session, _id: str, repository_name: str):
@rt(Routes.AddTable)
-def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str):
+def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries:str):
+ logger.debug(f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
- return instance.add_new_table(tab_id, form_id, repository_name, table_name)
+ return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries))
@rt(Routes.SelectRepository)
@@ -46,7 +48,7 @@ def put(session, _id: str, repository: str):
@rt(Routes.ShowTable)
-def get(session, _id: str, repository: str, table: str):
- logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}")
+def get(session, _id: str, repository: str, table: str, tab_boundaries:str):
+ logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
- return instance.show_table(repository, table)
+ return instance.show_table(repository, table, json.loads(tab_boundaries))
diff --git a/src/components/addstuff/assets/__init__.py b/src/components/repositories/__init__.py
similarity index 100%
rename from src/components/addstuff/assets/__init__.py
rename to src/components/repositories/__init__.py
diff --git a/src/components/repositories/assets/__init__.py b/src/components/repositories/assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/addstuff/assets/icons.py b/src/components/repositories/assets/icons.py
similarity index 100%
rename from src/components/addstuff/assets/icons.py
rename to src/components/repositories/assets/icons.py
diff --git a/src/components/repositories/commands.py b/src/components/repositories/commands.py
new file mode 100644
index 0000000..e8fe8b3
--- /dev/null
+++ b/src/components/repositories/commands.py
@@ -0,0 +1,48 @@
+from components.repositories.constants import ROUTE_ROOT, Routes
+
+
+class Commands:
+ def __init__(self, owner):
+ self._owner = owner
+ self._id = owner.get_id()
+
+ def request_add_repository(self):
+ return {
+ "hx-get": f"{ROUTE_ROOT}{Routes.AddRepository}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}"}}',
+ }
+
+ def add_repository(self, tab_id: str):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
+ "hx-target": f"#{self._id}",
+ "hx-swap": "beforeend",
+ "hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
+ }
+
+ def request_add_table(self, repository_name):
+ return {
+ "hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
+ }
+
+ def add_table(self, tab_id: str, repository_name: str):
+ return {
+ "hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ # The repository_name and the table_name will be given by the form
+ "hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "repository_name": "{repository_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
+ }
+
+ def show_table(self, repo_name, table_name):
+ return {
+ "hx_get": f"{ROUTE_ROOT}{Routes.ShowTable}",
+ "hx-target": f"#{self._owner.tabs_manager.get_id()}",
+ "hx-swap": "outerHTML",
+ "hx-vals": f'js:{{"_id": "{self._id}", "repository": "{repo_name}", "table": "{table_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
+ }
diff --git a/src/components/addstuff/components/Repositories.py b/src/components/repositories/components/Repositories.py
similarity index 73%
rename from src/components/addstuff/components/Repositories.py
rename to src/components/repositories/components/Repositories.py
index 5005b7b..8d50610 100644
--- a/src/components/addstuff/components/Repositories.py
+++ b/src/components/repositories/components/Repositories.py
@@ -4,14 +4,15 @@ from fasthtml.components import *
from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
-from components.addstuff.assets.icons import icon_database, icon_table
-from components.addstuff.commands import Commands
-from components.addstuff.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
-from components.addstuff.settings import RepositoriesDbManager, Repository
from components.datagrid_new.components.DataGrid import DataGrid
from components.form.components.MyForm import MyForm, FormField
-from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
+from components.repositories.assets.icons import icon_database, icon_table
+from components.repositories.commands import Commands
+from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
+from components.repositories.db_management import RepositoriesDbManager, Repository
+from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
+from core.utils import make_safe_id
logger = logging.getLogger("Repositories")
@@ -19,11 +20,11 @@ logger = logging.getLogger("Repositories")
class Repositories(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
- self._settings_manager = settings_manager
- self.db = RepositoriesDbManager(session, settings_manager)
+ self.commands = Commands(self)
self.tabs_manager = tabs_manager
+ self.db = RepositoriesDbManager(session, settings_manager)
+ self._settings_manager = settings_manager
self._contents = {} # ket tracks of already displayed contents
- self._commands = Commands(self)
def request_new_repository(self):
# request for a new tab_id
@@ -45,26 +46,27 @@ class Repositories(BaseComponent):
# create and display the form in a new tab
self.tabs_manager.add_tab("Add New Table", add_table_form, tab_id=new_tab_id)
- return self.tabs_manager
+ return self.tabs_manager.refresh()
- def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
+ def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
"""
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
:param form_id: form used to give the repository name (to be used in case of error)
:param repository_name: new repository name
:param table_name: default table name
+ :param tab_boundaries: tab boundaries
:return:
"""
try:
# Add the new repository and its default table to the list of repositories
- tables = [MyTable(table_name, {})] if table_name else []
+ tables = [table_name] if table_name else []
repository = self.db.add_repository(repository_name, tables)
# update the tab content with table content
key = (repository_name, table_name)
self.tabs_manager.set_tab_content(tab_id,
- self._get_table_content(key),
+ self._get_table_content(key, tab_boundaries),
title=table_name,
key=key,
active=True)
@@ -78,27 +80,28 @@ class Repositories(BaseComponent):
return self.tabs_manager.refresh()
- def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
+ def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
"""
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
:param form_id: form used to give the repository name (to be used in case of error)
:param repository_name: new repository name
:param table_name: default table name
+ :param tab_boundaries: tab boundaries
:return:
"""
try:
- self.db.add_table(repository_name, table_name, {})
+ self.db.add_table(repository_name, table_name)
repository = self.db.get_repository(repository_name)
# update the tab content with table content
key = (repository_name, table_name)
self.tabs_manager.set_tab_content(tab_id,
- self._get_table_content(key),
+ self._get_table_content(key, tab_boundaries),
title=table_name,
key=key,
active=True)
- return self._mk_repository(repository, True), self.tabs_manager.refresh()
+ return self._mk_repository(repository, True, oob=True), self.tabs_manager.refresh()
except ValueError as ex:
logger.debug(f" Repository '{repository_name}' already exists.")
@@ -110,33 +113,33 @@ class Repositories(BaseComponent):
def select_repository(self, repository_name: str):
self.db.select_repository(repository_name)
- def show_table(self, repository_name: str, table_name: str):
+ def show_table(self, repository_name: str, table_name: str, tab_boundaries: dict):
key = (repository_name, table_name)
- self.tabs_manager.add_tab(table_name, self._get_table_content(key), key)
- return self.tabs_manager
+ self.tabs_manager.add_tab(table_name, self._get_table_content(key, tab_boundaries), key)
+ return self.tabs_manager.refresh()
def refresh(self):
return self._mk_repositories(oob=True)
def __ft__(self):
return Div(
- mk_tooltip_container(self._id),
Div(cls="divider"),
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),
self._mk_repositories(),
- Script(f"bindRepositories('{self._id}')")
)
def _mk_repositories(self, oob=False):
- settings = self.db._get_settings()
+ repositories = self.db.get_repositories()
+ selected_repository_name = self.db.get_selected_repository()
return Div(
- *[self._mk_repository(repo, repo.name == settings.selected_repository_name)
- for repo in settings.repositories],
+ *[self._mk_repository(repo, repo.name == selected_repository_name)
+ for repo in repositories],
+ Div("+ Add Repository", cls="text-sm", **self.commands.request_add_repository()),
id=self._id,
hx_swap_oob="true" if oob else None,
)
- def _mk_repository(self, repo: Repository, selected):
+ def _mk_repository(self, repo: Repository, selected, oob=False) -> Div:
return Div(
Input(type="radio",
name=f"repo-accordion-{self._id}",
@@ -147,59 +150,59 @@ class Repositories(BaseComponent):
# hx_trigger="changed delay:500ms",
),
Div(
- mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name),
+ mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name, cls="text-sm"),
cls="collapse-title p-0 min-h-0 flex truncate",
),
Div(
*[
- Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table.name),
+ Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table_name, cls="text-sm"),
name="repo-table",
- hx_get=f"{ROUTE_ROOT}{Routes.ShowTable}",
- hx_target=f"#{self.tabs_manager.get_id()}",
- hx_swap="outerHTML",
- hx_vals=f'{{"_id": "{self._id}", "repository": "{repo.name}", "table": "{table.name}"}}',
- cls="flex")
- for table in repo.tables
+ cls="flex",
+ **self.commands.show_table(repo.name, table_name))
+ for table_name in repo.tables
],
- Div("+ Add Table", **self._commands.request_add_table(repo.name)),
+ Div("+ Add Table", cls="text-sm", **self.commands.request_add_table(repo.name)),
cls="collapse-content pr-0! truncate",
),
- tabindex="0", cls="collapse mb-2")
+ tabindex="0",
+ cls="collapse mb-2",
+ id=f"repo-{self._id}-{make_safe_id(repo.name)}",
+ hx_swap_oob="true" if oob else None,
+ )
def _mk_add_repository_form(self, tab_id: str):
- htmx_params = {
- "hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
- "hx-target": f"#{self._id}",
- "hx-swap": "beforeend",
- }
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
title="Add Repository",
fields=[FormField("repository", 'Repository Name', 'input'),
FormField("table", 'First Table Name', 'input')],
- htmx_params=htmx_params,
- extra_values={"_id": self._id, "tab_id": tab_id})
+ htmx_request=self.commands.add_repository(tab_id),
+ )
def _mk_add_table_form(self, tab_id: str, repository_name: str = None):
- htmx_request = self._commands.add_table()
+
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
title="Add Table",
fields=[FormField("repository_name", 'Repository Name', 'input',
value=repository_name,
disabled=True),
FormField("table_name", 'Table Name', 'input')],
- htmx_request=htmx_request,
- extra_values={"_id": self._id, "tab_id": tab_id, "repository_name": repository_name})
+ htmx_request=self.commands.add_table(tab_id, repository_name),
+ )
- def _get_table_content(self, key):
+ def _get_table_content(self, key, tab_boundaries: dict):
if key in self._contents:
- return self._contents[key]
+ content = self._contents[key]
+ if hasattr(content, "set_boundaries"):
+ content.set_boundaries(tab_boundaries)
+ return content
dg = InstanceManager.get(self._session,
DataGrid.create_component_id(self._session),
DataGrid,
settings_manager=self._settings_manager,
- key=key)
+ key=key,
+ boundaries=tab_boundaries)
self._contents[key] = dg
return dg
diff --git a/src/components/repositories/components/__init__.py b/src/components/repositories/components/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/repositories/constants.py b/src/components/repositories/constants.py
new file mode 100644
index 0000000..930b70b
--- /dev/null
+++ b/src/components/repositories/constants.py
@@ -0,0 +1,8 @@
+REPOSITORIES_INSTANCE_ID = "__Repositories__"
+ROUTE_ROOT = "/repositories"
+
+class Routes:
+ AddRepository = "/add-repo"
+ SelectRepository = "/select-repo"
+ AddTable = "/add-table"
+ ShowTable = "/show-table"
\ No newline at end of file
diff --git a/src/components/repositories/db_management.py b/src/components/repositories/db_management.py
new file mode 100644
index 0000000..07e72ea
--- /dev/null
+++ b/src/components/repositories/db_management.py
@@ -0,0 +1,185 @@
+import dataclasses
+import logging
+
+from core.settings_management import SettingsManager
+
+REPOSITORIES_SETTINGS_ENTRY = "Repositories"
+
+logger = logging.getLogger("AddStuffSettings")
+
+
+@dataclasses.dataclass
+class Repository:
+ name: str
+ tables: list[str]
+
+
+@dataclasses.dataclass
+class RepositoriesSettings:
+ repositories: list[Repository] = dataclasses.field(default_factory=list)
+ selected_repository_name: str = None
+
+ @staticmethod
+ def use_refs():
+ return {"repositories"}
+
+
+class RepositoriesDbManager:
+
+ def __init__(self, session: dict, settings_manager: SettingsManager):
+ self.session = session
+ self.settings_manager = settings_manager
+
+ def add_repository(self, repository_name: str, tables: list[str] = None):
+ """
+ Adds a new repository to the list of repositories. The repository is identified
+ by its name and can optionally include a list of associated tables.
+
+ :param repository_name: The name of the repository to be added.
+ :param tables: A list of Table objects to be associated with the repository,
+ defaulting to an empty list if not provided.
+ :type tables: list[Table], optional
+ :return: None
+ """
+
+ settings = self._get_settings()
+
+ if repository_name is None or repository_name == "":
+ raise ValueError("Repository name cannot be empty.")
+
+ if repository_name in [repo.name for repo in settings.repositories]:
+ raise ValueError(f"Repository '{repository_name}' already exists.")
+
+ existing_repositories = [r.name for r in settings.repositories]
+ logger.info(f"Existing repositories:{existing_repositories}")
+
+ repository = Repository(repository_name, tables or [])
+ settings.repositories.append(repository)
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+ return repository
+
+ def get_repository(self, repository_name: str):
+ if repository_name is None or repository_name == "":
+ raise ValueError("Repository name cannot be empty.")
+
+ settings = self._get_settings()
+ if repository_name not in [repo.name for repo in settings.repositories]:
+ raise ValueError(f"Repository '{repository_name}' does not exist.")
+
+ return next(filter(lambda r: r.name == repository_name, settings.repositories))
+
+ def modify_repository(self, old_repository_name, new_repository_name: str, tables: list[str]):
+ if not old_repository_name or not new_repository_name:
+ raise ValueError("Repository name cannot be empty.")
+
+ settings = self._get_settings()
+ for repository in settings.repositories:
+ if repository.name == old_repository_name:
+ repository.name = new_repository_name
+ repository.tables = tables
+
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+ return repository
+
+ else:
+ raise ValueError(f"Repository '{old_repository_name}' not found.")
+
+ def remove_repository(self, repository_name):
+ if not repository_name:
+ raise ValueError("Repository name cannot be empty.")
+
+ settings = self._get_settings()
+ repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
+ if repository is None:
+ raise ValueError(f"Repository '{repository_name}' does not exist.")
+
+ settings.repositories.remove(repository)
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+ return repository
+
+ def get_repositories(self):
+ return self._get_settings().repositories
+
+ def add_table(self, repository_name: str, table_name: str):
+ """
+ Adds a table to the specified repository
+
+ :param repository_name: The name of the target repository.
+ :param table_name: The name of the table to add.
+ :return: None
+ """
+ settings, repository = self._get_settings_and_repo(repository_name, table_name, t1_must_exists=False)
+
+ repository.tables.append(table_name)
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+
+ def modify_table(self, repository_name: str, old_table_name: str, new_table_name: str):
+ """
+ Modifies the name of a table in the specified repository.
+
+ :param repository_name: The name of the repository containing the table.
+ :param old_table_name: The current name of the table to be modified.
+ :param new_table_name: The new name for the table.
+ :return: None
+ """
+ settings, repository = self._get_settings_and_repo(repository_name, old_table_name, new_table_name)
+
+ table_index = repository.tables.index(old_table_name)
+ repository.tables[table_index] = new_table_name
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+
+ def remove_table(self, repository_name: str, table_name: str):
+ """
+ Removes a table from the specified repository.
+
+ :param repository_name: The name of the repository containing the table.
+ :param table_name: The name of the table to be removed.
+ :return: None
+ """
+ settings, repository = self._get_settings_and_repo(repository_name, table_name)
+
+ repository.tables.remove(table_name)
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+
+ def select_repository(self, repository_name: str):
+ """
+ Select and save the specified repository name in the current session's settings.
+
+ :param repository_name: The name of the repository to be selected and stored.
+ :type repository_name: str
+ :return: None
+ """
+ settings = self._get_settings()
+ settings.selected_repository_name = repository_name
+ self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
+
+ def get_selected_repository(self):
+ settings = self._get_settings()
+ return settings.selected_repository_name
+
+ def _get_settings(self):
+ return self.settings_manager.load(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
+
+ def _get_settings_and_repo(self, repository_name, *tables_names, t1_must_exists=True, t2_must_exists=False):
+
+ if not repository_name:
+ raise ValueError("Repository name cannot be empty.")
+
+ for table_name in tables_names:
+ if not table_name:
+ raise ValueError("Table name cannot be empty.")
+
+ settings = self._get_settings()
+ repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
+ if repository is None:
+ raise ValueError(f"Repository '{repository_name}' does not exist.")
+
+ for table_name, must_exist in zip(tables_names, [t1_must_exists, t2_must_exists]):
+ if must_exist:
+ if table_name not in repository.tables:
+ raise ValueError(f"Table '{table_name}' does not exist in repository '{repository_name}'.")
+ else:
+ if table_name in repository.tables:
+ raise ValueError(f"Table '{table_name}' already exists in repository '{repository_name}'.")
+
+ return settings, repository
diff --git a/src/components/tabs/assets/tabs.css b/src/components/tabs/assets/tabs.css
index 34eeb8f..e6c7787 100644
--- a/src/components/tabs/assets/tabs.css
+++ b/src/components/tabs/assets/tabs.css
@@ -1,20 +1,27 @@
-.tabs {
- background-color: var(--color-base-200);
- color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
- border-radius: .5rem;
- width: 100%;
- height: 100%;
+.mmt-tabs {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--color-base-200);
+ color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
+ border-radius: .5rem;
+ width: 100%;
+ height: 100%;
}
-.tabs-content {
+.mmt-tabs-header {
+ display: flex;
+ min-height: 25px;
+}
+
+.mmt-tabs-content {
display: block;
width: 100%;
- height: 100%;
+ flex: 1;
background-color: var(--color-base-100);
padding: 4px;
}
-.tabs-tab {
+.mmt-tabs-tab {
cursor: pointer;
appearance: none;
text-align: center;
@@ -28,15 +35,15 @@
}
-.tabs-tab:hover {
+.mmt-tabs-tab:hover {
color: var(--color-base-content); /* Change text color on hover */
}
-.tabs-label {
+.mmt-tabs-label {
max-width: 150px;
}
-.tabs-active {
+.mmt-tabs-active {
--depth: 1;
background-color: var(--color-base-100);
color: var(--color-base-content);
diff --git a/src/components/tabs/assets/tabs.js b/src/components/tabs/assets/tabs.js
index e28f811..526b194 100644
--- a/src/components/tabs/assets/tabs.js
+++ b/src/components/tabs/assets/tabs.js
@@ -1,3 +1,11 @@
-function bindTabs(tabsId) {
- bindTooltipsWithDelegation(tabsId)
+function getTabContentBoundaries(tabsId) {
+ const tabsContainer = document.getElementById(tabsId)
+ console.debug("tabsContainer", tabsContainer)
+ const contentDiv = tabsContainer.querySelector('.mmt-tabs-content')
+
+ const boundaries = contentDiv.getBoundingClientRect()
+ return {
+ width: boundaries.width,
+ height: boundaries.height
+ }
}
\ No newline at end of file
diff --git a/src/components/tabs/components/MyTabs.py b/src/components/tabs/components/MyTabs.py
index a019462..1ba773f 100644
--- a/src/components/tabs/components/MyTabs.py
+++ b/src/components/tabs/components/MyTabs.py
@@ -2,12 +2,11 @@ import dataclasses
import logging
from fasthtml.components import *
-from fasthtml.xtend import Script
from assets.icons import icon_dismiss_regular
from components.BaseComponent import BaseComponent
from components.tabs.constants import MY_TABS_INSTANCE_ID, Routes, ROUTE_ROOT
-from components_helpers import mk_ellipsis, mk_tooltip_container
+from components_helpers import mk_ellipsis
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
@@ -151,12 +150,9 @@ class MyTabs(BaseComponent):
return self.render(oob=True)
def __ft__(self):
- return mk_tooltip_container(self._id), self.render(), Script(f"bindTabs('{self._id}')")
+ return self.render()
def render(self, oob=False):
- if not self.tabs:
- return Div(id=self._id, hx_swap_oob="true" if oob else None)
-
active_content = self.get_active_tab_content()
if hasattr(active_content, "on_htmx_after_settle"):
extra_params = {"hx-on::after-settle": active_content.on_htmx_after_settle()}
@@ -164,9 +160,9 @@ class MyTabs(BaseComponent):
extra_params = {}
return Div(
- *[self._mk_tab(tab) for tab in self.tabs], # headers
- Div(active_content, cls="tabs-content"),
- cls="tabs",
+ Div(*[self._mk_tab(tab) for tab in self.tabs], cls="mmt-tabs-header"), # headers
+ Div(active_content, cls="mmt-tabs-content"),
+ cls="mmt-tabs",
id=self._id,
hx_swap_oob="true" if oob else None,
**extra_params,
@@ -174,9 +170,9 @@ class MyTabs(BaseComponent):
def _mk_tab(self, tab: Tab):
return Span(
- Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="tabs-label truncate"),
+ Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="mmt-tabs-label truncate"),
Div(icon_dismiss_regular, cls="icon-16 ml-2", hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
- cls=f"tabs-tab {'tabs-active' if tab.active else ''}",
+ cls=f"mmt-tabs-tab {'mmt-tabs-active' if tab.active else ''}",
hx_vals=f'{{"_id": "{self._id}", "tab_id":"{tab.id}"}}',
hx_target=f"#{self._id}",
hx_swap="outerHTML",
diff --git a/src/components/themecontroller/ThemeControllerApp.py b/src/components/themecontroller/ThemeControllerApp.py
index e55fadc..d883cb1 100644
--- a/src/components/themecontroller/ThemeControllerApp.py
+++ b/src/components/themecontroller/ThemeControllerApp.py
@@ -3,7 +3,7 @@ import logging
from fasthtml.fastapp import fast_app
from components.themecontroller.constants import Routes
-from core.instance_manager import InstanceManager
+from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("ThemeControllerApp")
@@ -12,6 +12,6 @@ theme_controller_app, rt = fast_app()
@rt(Routes.ChangeTheme)
def post(session, _id: str, theme: str):
- logger.debug(f"Entering {Routes.ChangeTheme} with args {session=}, {theme=}")
+ logger.debug(f"Entering {Routes.ChangeTheme} with args {debug_session(session)}, {theme=}")
instance = InstanceManager.get(session, _id)
instance.change_theme(theme)
diff --git a/src/components/themecontroller/components/ThemeContoller.py b/src/components/themecontroller/components/ThemeContoller.py
index e399207..0efa29a 100644
--- a/src/components/themecontroller/components/ThemeContoller.py
+++ b/src/components/themecontroller/components/ThemeContoller.py
@@ -6,7 +6,7 @@ from fasthtml.svg import *
from components.themecontroller.constants import ROUTE_ROOT, Routes
from components.themecontroller.settings import THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings
-from core.settings_management import SettingsManager
+from core.settings_management import SettingsManager, GenericDbManager
logger = logging.getLogger("ThemeController")
@@ -14,12 +14,7 @@ logger = logging.getLogger("ThemeController")
class ThemeController:
def __init__(self, session, settings_manager: SettingsManager, /, _id=None):
self._id = _id or uuid.uuid4().hex
- self.session = session
- self.settings_manager = settings_manager
-
- self.settings = self.settings_manager.get(session,
- THEME_CONTROLLER_SETTINGS_ENTRY,
- default=ThemeControllerSettings())
+ self.db = GenericDbManager(session, settings_manager, THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings)
def __ft__(self):
return Div(
@@ -46,7 +41,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Default', value='default',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
- checked=self.settings.theme is None or self.settings.theme == 'default',
+ checked=self.db.theme is None or self.db.theme == 'default',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -54,7 +49,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Dark', value='dark',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
- checked=self.settings.theme == 'dark',
+ checked=self.db.theme == 'dark',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -62,7 +57,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Light', value='light',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
- checked=self.settings.theme == 'light',
+ checked=self.db.theme == 'light',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -70,15 +65,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Cupcake', value='cupcake',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
- checked=self.settings.theme == 'cupcake',
- hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
- hx_vals=f'{{"_id": "{self._id}"}}',
- )
- ),
- Li(
- Input(type='radio', name='theme', aria_label='Lofi', value='lofi',
- cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
- checked=self.settings.theme == 'lofi',
+ checked=self.db.theme == 'cupcake',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -91,5 +78,4 @@ class ThemeController:
def change_theme(self, theme):
logger.debug(f"change_theme - Changing theme to '{theme}'.")
- self.settings.theme = theme
- self.settings_manager.put(self.session, THEME_CONTROLLER_SETTINGS_ENTRY, self.settings)
+ self.db.theme = theme
diff --git a/src/components/themecontroller/settings.py b/src/components/themecontroller/settings.py
index cce4ebc..e1c4041 100644
--- a/src/components/themecontroller/settings.py
+++ b/src/components/themecontroller/settings.py
@@ -2,7 +2,7 @@ import dataclasses
from core.settings_objects import BaseSettingObj
-THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeControllerSettings"
+THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeController"
@dataclasses.dataclass
diff --git a/src/components_helpers.py b/src/components_helpers.py
index 7325688..aea6d5a 100644
--- a/src/components_helpers.py
+++ b/src/components_helpers.py
@@ -19,7 +19,7 @@ def mk_ellipsis(txt: str, cls='', **kwargs):
def mk_tooltip_container(component_id):
- return Div(id=f"tt_{component_id}", style="position: fixed; z-index: 1000;", cls="mmt-tooltip-container"),
+ return Div(id=f"tt_{component_id}", style="position: fixed; z-index: 1000;", cls="mmt-tooltip-container")
def mk_dialog_buttons(ok_title: str = "OK", cancel_title: str = "Cancel", on_ok: dict = None, on_cancel: dict = None):
diff --git a/src/config.py b/src/config.py
index 423c1a1..02982ce 100644
--- a/src/config.py
+++ b/src/config.py
@@ -28,8 +28,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
# GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "/auth/github/callback")
# Admin user (created on first run if provided)
-ADMIN_EMAIL = os.getenv("ADMIN_EMAIL")
-ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
+ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin")
+ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
logger.info(f"{ADMIN_EMAIL=}")
# Session expiration (in seconds)
diff --git a/src/core/dbengine.py b/src/core/dbengine.py
index 9514131..cb2e951 100644
--- a/src/core/dbengine.py
+++ b/src/core/dbengine.py
@@ -69,12 +69,10 @@ class DbEngine:
Designed to keep history of the modifications
"""
ObjectsFolder = "objects" # group objects in the same folder
- HeadFile = "head" # used to keep track the latest version of all entries
+ HeadFile = "head" # used to keep track of the latest version of all entries
def __init__(self, root: str = None):
self.root = root or ".mytools_db"
- self.serializer = Serializer(RefHelper(self._get_ref_path))
- self.debug_serializer = DebugSerializer(RefHelper(self._get_ref_path))
self.lock = RLock()
def is_initialized(self, user_id: str):
@@ -196,7 +194,7 @@ class DbEngine:
self.save(user_id, user_email, entry, entry_content)
return True
- def put_many(self, user_id: str, user_email, entry, items: list):
+ def put_many(self, user_id: str, user_email, entry, items: list | dict):
"""
Save a list of item as one single snapshot
A new snapshot will not be created if all the items already exist
@@ -216,13 +214,24 @@ class DbEngine:
entry_content = {}
is_dirty = False
- for item in items:
- key = item.get_key()
- if key in entry_content and entry_content[key] == item:
- continue
- else:
- entry_content[key] = item
- is_dirty = True
+
+ if isinstance(items, dict):
+ for key, item in items.items():
+ if key in entry_content and entry_content[key] == item:
+ continue
+ else:
+ entry_content[key] = item
+ is_dirty = True
+
+ else:
+
+ for item in items:
+ key = item.get_key()
+ if key in entry_content and entry_content[key] == item:
+ continue
+ else:
+ entry_content[key] = item
+ is_dirty = True
if is_dirty:
self.save(user_id, user_email, entry, entry_content)
@@ -257,7 +266,10 @@ class DbEngine:
# return all items as list
return [v for k, v in entry_content.items() if not k.startswith("__")]
- return entry_content[key]
+ try:
+ return entry_content[key]
+ except KeyError:
+ raise DbException(f"Key '{key}' not found in entry '{entry}'")
def debug_root(self):
"""
@@ -287,7 +299,18 @@ class DbEngine:
with open(target_file, 'r', encoding='utf-8') as file:
as_dict = json.load(file)
- return self.debug_serializer.deserialize(as_dict)
+ debug_serializer = DebugSerializer(RefHelper(self._get_ref_path))
+ return debug_serializer.deserialize(as_dict)
+
+ def debug_users(self):
+ """
+ Returns a list of all user folders inside the root directory, excluding the 'refs' folder
+ :return: List of folder names
+ """
+ with self.lock:
+ if not os.path.exists(self.root):
+ return []
+ return [f for f in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, f)) and f != 'refs']
def debug_get_digest(self, user_id, entry):
return self._get_entry_digest(user_id, entry)
@@ -298,12 +321,15 @@ class DbEngine:
:param obj:
:return:
"""
- # serializer = Serializer(RefHelper(self._get_obj_path))
- use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
- return self.serializer.serialize(obj, use_refs)
+ with self.lock:
+ serializer = Serializer(RefHelper(self._get_ref_path))
+ use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
+ return serializer.serialize(obj, use_refs)
def _deserialize(self, as_dict):
- return self.serializer.deserialize(as_dict)
+ with self.lock:
+ serializer = Serializer(RefHelper(self._get_ref_path))
+ return serializer.deserialize(as_dict)
def _update_head(self, user_id, entry, digest):
"""
@@ -368,4 +394,4 @@ class DbEngine:
:param digest:
:return:
"""
- return os.path.join(self.root, "refs", digest[:24], digest)
\ No newline at end of file
+ return os.path.join(self.root, "refs", digest[:24], digest)
diff --git a/src/core/settings_management.py b/src/core/settings_management.py
index 69c1376..4258984 100644
--- a/src/core/settings_management.py
+++ b/src/core/settings_management.py
@@ -1,8 +1,7 @@
-import json
import logging
-import os.path
+from datetime import datetime
-from core.dbengine import DbEngine, DbException
+from core.dbengine import DbEngine, TAG_PARENT, TAG_USER, TAG_DATE, DbException
from core.instance_manager import NO_SESSION, NOT_LOGGED
from core.settings_objects import *
@@ -20,56 +19,6 @@ class NoDefaultCls:
NoDefault = NoDefaultCls()
-class DummyDbEngine:
- """
- Dummy DB engine
- Can only serialize object defined in settings_object module
- Save everything in a single file
- """
-
- def __init__(self, setting_path="settings.json"):
- self.db_path = setting_path
-
- def save(self, user_id: str, entry: str, obj: object) -> bool:
- if not hasattr(obj, "as_dict"):
- raise Exception("'as_dict' not found. Not supported")
-
- as_dict = getattr(obj, "as_dict")()
- as_dict["__type__"] = type(obj).__name__
-
- if os.path.exists(self.db_path):
- with open(self.db_path, "r") as settings_file:
- as_json = json.load(settings_file)
- as_json[entry] = as_dict
- with open(self.db_path, "w") as settings_file:
- json.dump(as_json, settings_file)
- else:
- as_json = {entry: as_dict}
- with open(self.db_path, "w") as settings_file:
- json.dump(as_json, settings_file)
-
- return True
-
- def load(self, user_id: str, entry: str, digest: str = None):
- try:
- with open(self.db_path, "r") as settings_file:
- as_json = json.load(settings_file)
-
- as_dict = as_json[entry]
- obj_type = as_dict.pop("__type__")
- obj = globals()[obj_type]()
- getattr(obj, "from_dict")(as_dict)
- return obj
- except Exception as ex:
- raise DbException(f"Entry '{entry}' is not found.")
-
- def is_initialized(self):
- return os.path.exists(self.db_path)
-
- def init(self):
- pass
-
-
class MemoryDbEngine:
"""
Keeps everything in memory
@@ -81,127 +30,108 @@ class MemoryDbEngine:
def init_db(self, entry, key, obj):
self.db[entry] = {key: obj}
- def save(self, user_id: str, entry: str, obj: object) -> bool:
- self.db[entry] = obj
+ def save(self, user_id: str, user_email: str, entry: str, obj: object) -> bool:
+ if user_id in self.db:
+ self.db[user_id][entry] = obj
+ self.db[user_id][TAG_PARENT] = [] # not used
+ self.db[user_id][TAG_USER] = user_email
+ self.db[user_id][TAG_DATE] = datetime.now().strftime('%Y%m%d %H:%M:%S %z')
+ else:
+ self.db[user_id] = {
+ entry: obj,
+ TAG_PARENT: [], # not set
+ TAG_USER: user_email,
+ TAG_DATE: datetime.now().strftime('%Y%m%d %H:%M:%S %z')
+ }
return True
def load(self, user_id: str, entry: str, digest: str = None):
try:
- return self.db[entry]
- except KeyError:
- return {}
+ return self.db[user_id][entry]
+ except KeyError as e:
+ raise DbException(e)
def get(self, user_id: str, entry: str, key: str | None = None, digest=None):
- return self.db[entry][key]
+ """
+ Retrieve a specific value or the entire object from stored data based on the
+ provided user ID and entry name. Optionally, a key can be specified to extract
+ a particular value from the loaded object.
+
+ :param user_id: The unique identifier of the user associated with the data
+ being retrieved.
+ :param entry: The name of the entry under the user's data storage.
+ :param key: Optional; Specific key for retrieving a particular value from the
+ entry. If not provided, the entire object is returned.
+ :param digest: Optional; Used to specify a digest or additional parameter,
+ but its function should be inferred from its use, as it is not directly
+ handled in this method.
+ :return: The value corresponding to the specified key within the user's entry,
+ or the entire entry object if no key is specified.
+ """
+ obj = self.load(user_id, entry)
+ try:
+ return obj[key] if key else obj
+ except KeyError as e:
+ raise DbException(e)
- def put(self, user_id: str, entry, key: str, value: object):
- if entry not in self.db:
- self.db[entry] = {}
- self.db[entry][key] = value
+ def put(self, user_id: str, user_email: str, entry, key: str, value: object):
+ obj = self.load(user_id, entry)
+ obj[key] = value
- def is_initialized(self):
- return True
+ def put_many(self, user_id: str, user_email: str, entry, items):
+ obj = self.load(user_id, entry)
+ obj.update(items)
+
+ def exists(self, user_id: str, entry: str):
+ return user_id in entry and entry in self.db[user_id]
class SettingsManager:
def __init__(self, engine=None):
self._db_engine = engine or DbEngine()
- def save(self, user_id: str, entry: str, obj: object):
- return self._db_engine.save(user_id, entry, obj)
+ def save(self, session: dict, entry: str, obj: object):
+ user_id, user_email = self._get_user(session)
+ return self._db_engine.save(user_id, user_email, entry, obj)
- def load(self, user_id: str, entry: str):
- return self._db_engine.load(user_id, entry)
-
- def get_all(self, user_id: str, entry: str):
- """"
- Returns all the items of an entry
- """
- return self._db_engine.get(user_id, entry, None)
-
- def put(self, session: dict, key: str, value: object):
- """
- Inserts or updates a key-value pair in the database for the current user session.
- The method extracts the user ID and email from the session dictionary and
- utilizes the database engine to perform the storage operation.
-
- :param session: A dictionary containing session-specific details,
- including 'user_id' and 'user_email'.
- :type session: dict
- :param key: The key under which the value should be stored in the database.
- :type key: str
- :param value: The value to be stored, associated with the specified key.
- :type value: object
- :return: The result of the database engine's put operation.
- :rtype: object
- """
- user_id = session["user_id"] if session else NO_SESSION
- user_email = session["user_email"] if session else NOT_LOGGED
- return self._db_engine.put(user_email, str(user_id), key, value)
-
- def get(self, session: dict, key: str | None = None, default=NoDefault):
- """
- Fetches a value associated with a specific key for a user session from the
- database. If the key is not found in the database and a default value is
- provided, returns the default value. If no default is provided and the key
- is not found, raises a KeyError.
-
- :param session: A dictionary containing session data. Must include "user_id"
- and "user_email" keys.
- :type session: dict
- :param key: The key to fetch from the database for the given session user.
- Defaults to None if not specified.
- :type key: str | None
- :param default: The default value to return if the key is not found in the
- database. If not provided, raises KeyError when the key is missing.
- :type default: Any
- :return: The value associated with the key for the user session if found in
- the database, or the provided default value if the key is not found.
- """
+ def load(self, session: dict, entry: str, default=NoDefault):
+ user_id, _ = self._get_user(session)
try:
- user_id = session["user_id"] if session else NO_SESSION
- user_email = session["user_email"] if session else NOT_LOGGED
-
- return self._db_engine.get(user_email, str(user_id), key)
- except KeyError:
+ return self._db_engine.load(user_id, entry)
+ except DbException:
+ return default
+
+ def put(self, session: dict, entry: str, key: str, value: object):
+ user_id, user_email = self._get_user(session)
+ return self._db_engine.put(user_id, user_email, entry, key, value)
+
+ def put_many(self, session: dict, entry: str, items: list | dict):
+ user_id, user_email = self._get_user(session)
+ return self._db_engine.put_many(user_id, user_email, entry, items)
+
+ def get(self, session: dict, entry: str, key: str | None = None, default=NoDefault):
+ try:
+ user_id, _ = self._get_user(session)
+ return self._db_engine.get(user_id, entry, key)
+ except DbException:
if default is NoDefault:
raise
else:
return default
- def remove(self, session: dict, key: str):
- user_id = session["user_id"] if session else NO_SESSION
- user_email = session["user_email"] if session else NOT_LOGGED
- return self._db_engine.remove(user_email, user_id, key)
+ def exists(self, session: dict, entry: str):
+ user_id, _ = self._get_user(session)
- def update(self, session: dict, old_key: str, key: str, value: object):
- user_id = session["user_id"] if session else NO_SESSION
- user_email = session["user_email"] if session else NOT_LOGGED
-
- def _update_helper(_old_key, _key, _value):
- pass
-
- if hasattr(self._db_engine, "lock"):
- with self._db_engine.lock:
- _update_helper(old_key, key, value)
- else:
- _update_helper(old_key, key, value)
-
- def init_user(self, user_id: str, user_email: str):
- """
- Init the settings block space for a user
- :param user_id:
- :param user_email:
- :return:
- """
- if not self._db_engine.exists(user_id):
- self._db_engine.save(user_email, user_id, {})
-
- def get_db_engine_root(self):
- return os.path.abspath(self._db_engine.root)
+ return self._db_engine.exists(user_id, entry)
def get_db_engine(self):
return self._db_engine
+
+ @staticmethod
+ def _get_user(session):
+ user_id = str(session.get("user_id", NOT_LOGGED)) if session else NO_SESSION
+ user_email = session.get("user_email", NOT_LOGGED) if session else NO_SESSION
+ return user_id, user_email
class SettingsTransaction:
@@ -223,6 +153,31 @@ class SettingsTransaction:
if exc_type is None:
self._settings_manager.save(self._user_email, self._user_id, self._entries)
-#
-# settings_manager = SettingsManager()
-# settings_manager.init()
+
+class GenericDbManager:
+ def __init__(self, session, settings_manager: SettingsManager, obj_entry, obj_type):
+ self.__dict__["_session"] = session
+ self.__dict__["_settings_manager"] = settings_manager
+ self.__dict__["_obj_entry"] = obj_entry
+ self.__dict__["_obj_type"] = obj_type
+
+ def __setattr__(self, key, value):
+ if key.startswith("_"):
+ super().__setattr__(key, value)
+
+ settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
+ if not (hasattr(settings, key)):
+ raise AttributeError(f"Settings {self._obj_entry.__name__} has no attribute {key}")
+
+ setattr(settings, key, value)
+ self._settings_manager.save(self._session, self._obj_entry, settings)
+
+ def __getattr__(self, item):
+ if item.startswith("_"):
+ return super().__getattribute__(item)
+
+ settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
+ if not (hasattr(settings, item)):
+ raise AttributeError(f"Settings {self._obj_entry.__name__} has no attribute {item}")
+
+ return getattr(settings, item)
diff --git a/src/core/utils.py b/src/core/utils.py
index a802ccf..2db6064 100644
--- a/src/core/utils.py
+++ b/src/core/utils.py
@@ -199,7 +199,7 @@ def snake_case_to_capitalized_words(s: str) -> str:
return transformed_name
-def make_column_id(s: str | None):
+def make_safe_id(s: str | None):
if s is None:
return None
diff --git a/src/main.py b/src/main.py
index 318802c..959c785 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,42 +1,26 @@
# global layout
import logging.config
+import requests
import yaml
from fasthtml.common import *
-from assets.css import my_managing_tools_style
from auth.auth_manager import AuthManager
from components.DrawerLayoutOld import DrawerLayout as DrawerLayoutOld
from components.DrawerLayoutOld import Page
-from components.addstuff.AddStuffApp import add_stuff_app
-from components.addstuff.constants import ROUTE_ROOT as ADD_STUFF_ROUTE_ROOT
from components.datagrid.DataGrid import DATAGRID_PATH, datagrid_app
-from components.datagrid_new.DataGridApp import datagrid_new_app
-from components.datagrid_new.constants import ROUTE_ROOT as DATAGRID_NEW_ROUTE_ROOT
-from components.debugger.DebuggerApp import debugger_app
-from components.debugger.constants import ROUTE_ROOT as DEBUGGER_ROUTE_ROOT
-from components.drawerlayout.DrawerLayoutApp import drawer_layout_app
from components.drawerlayout.components import DrawerLayout
from components.drawerlayout.components.DrawerLayout import DrawerLayout
-from components.drawerlayout.constants import ROUTE_ROOT as DRAWER_LAYOUT_ROUTE_ROOT
-from components.form.FormApp import form_app
-from components.form.constants import ROUTE_ROOT as FORM_ROUTE_ROOT
-from components.login.LoginApp import login_app
from components.login.components.Login import Login
from components.login.constants import ROUTE_ROOT as LOGIN_ROUTE_ROOT
from components.login.constants import Routes as LoginRoutes
from components.page_layout_new import page_layout_new, page_layout_lite
-from components.register.RegisterApp import register_app
from components.register.components.Register import Register
from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
from components.register.constants import Routes as RegisterRoutes
-from components.tabs.TabsApp import tabs_app
-from components.tabs.constants import ROUTE_ROOT as TABS_ROUTE_ROOT
-from components.themecontroller.ThemeControllerApp import theme_controller_app
-from components.themecontroller.constants import ROUTE_ROOT as THEME_CONTROLLER_ROUTE_ROOT
from constants import Routes
from core.dbengine import DbException
-from core.instance_manager import NO_SESSION, NOT_LOGGED, InstanceManager
+from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
from pages.another_grid import get_datagrid2
@@ -52,59 +36,148 @@ with open('logging.yaml', 'r') as f:
# At the top of your script or module
logging.config.dictConfig(config)
+logger = logging.getLogger("MainApp")
+
# daisy_ui_links_v4 = (
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
# Script(src="https://cdn.tailwindcss.com"),
# )
-daisy_ui_links_v4 = (
- Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
- Script(src="./assets/tailwindcss.js"),
-)
-
-daisy_ui_links = (
+links = [
+ # start with daisyui
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5", rel="stylesheet", type="text/css"),
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css", rel="stylesheet", type="text/css"),
Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),
-)
-
-main_links = (Script(src="./assets/main.js"),
- Link(rel="stylesheet", href="./assets/main.css", type="text/css"),)
-
-drawer_layout = (Script(src="./components/drawerlayout/assets/DrawerLayout.js"),
- Link(rel="stylesheet", href="./components/drawerlayout/assets/DrawerLayout.css"),)
-
-datagridOld = (Script(src="./components/datagrid/DataGrid.js"),
- Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"))
-
-drw_layout_old = (Script(src="./assets/DrawerLayout.js", defer=True),
- Link(rel="stylesheet", href="./assets/DrawerLayout.css"))
-
-datagrid = (Script(src="./components/datagrid_new/assets/Datagrid.js"),
- Link(rel="stylesheet", href="./components/datagrid_new/assets/Datagrid.css"))
-
-addstuff = (Script(src="./components/addstuff/assets/addstuff.js"),)
-
-tabs = (Script(src="./components/tabs/assets/tabs.js"),
- Link(rel="stylesheet", href="./components/tabs/assets/tabs.css"),)
-
-debugger = (Script(type="module", src="./components/debugger/assets/Debugger.js"),)
-
-routes = (
- Mount(LOGIN_ROUTE_ROOT, login_app, name="login"),
- Mount(REGISTER_ROUTE_ROOT, register_app, name="register"),
- Mount(THEME_CONTROLLER_ROUTE_ROOT, theme_controller_app, name="theme_controller"),
- Mount(DRAWER_LAYOUT_ROUTE_ROOT, drawer_layout_app, name="main"),
- Mount(ADD_STUFF_ROUTE_ROOT, add_stuff_app, name="add_stuff"),
- Mount(TABS_ROUTE_ROOT, tabs_app, name="tabs"),
- Mount(FORM_ROUTE_ROOT, form_app, name="form"),
- Mount(DATAGRID_NEW_ROUTE_ROOT, datagrid_new_app, name="datagrid_new"),
- Mount(DEBUGGER_ROUTE_ROOT, debugger_app, name="debugger"),
+ # Old drawer layout
+ Script(src="./assets/DrawerLayout.js", defer=True),
+ Link(rel="stylesheet", href="./assets/DrawerLayout.css"),
+
+ # Old datagrid
+ Script(src="./components/datagrid/DataGrid.js"),
+ Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
+
+ # add main
+ Script(src="./assets/main.js"),
+ Link(rel="stylesheet", href="./assets/main.css", type="text/css")
+]
+
+routes = []
+
+
+def register_component(name, path, app_module_name):
+ def _get_fast_app(_module):
+ if app_module_name is None:
+ logger.debug(f" No app module defined for {_module.__name__}. Skipping fast app lookup.")
+ return None
+
+ for var_name in dir(_module):
+ if var_name.endswith('App'):
+ component_module = getattr(_module, var_name)
+ logger.debug(f" Found component module {component_module.__name__}")
+ for sub_var_name in dir(component_module):
+ if sub_var_name.endswith("_app"):
+ res = getattr(component_module, sub_var_name)
+ if isinstance(res, FastHTML):
+ logger.debug(f" Found FastHTML app component {sub_var_name}")
+ return res
+
+ raise ValueError(f" Cannot find app for component {name}")
+
+ def _get_route_root(_module):
+ if app_module_name is None:
+ logger.debug(f" No app module defined for {_module.__name__}. Skipping route root lookup.")
+ return None
+
+ constants_module = getattr(_module, "constants")
+ return getattr(constants_module, "ROUTE_ROOT")
+
+ def _get_links(_module):
+ # Get module file path and construct assets directory path
+ module_path = os.path.dirname(_module.__file__)
+ assets_path = os.path.join(module_path, 'assets')
+
+ if not os.path.exists(assets_path):
+ logger.debug(f" No 'assets' folder not found in module {_module.__name__}")
+ return None
+
+ # Find all CSS files in assets directory
+ files = []
+ for file in os.listdir(assets_path):
+ if file.endswith('.css'):
+ file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
+ files.append(Link(rel="stylesheet", href=file_path, type="text/css"))
+ logger.debug(f" Found CSS file {file_path}")
+ elif file.endswith('.js'):
+ file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
+ files.append(Script(src=file_path))
+ logger.debug(f" Found JS file {file_path}")
+
+ return files if files else None
+
+ try:
+ # Import the module dynamically
+ logger.debug(f"register_component {name=} {path=} {app_module_name=}")
+ module = __import__(path, fromlist=['*', app_module_name] if app_module_name else ['*'])
+
+ component_app = _get_fast_app(module)
+ component_route_root = _get_route_root(module)
+ component_links = _get_links(module)
+
+ if component_app is not None:
+ routes.append(Mount(component_route_root, component_app, name=name))
+
+ if component_links is not None:
+ links.extend(component_links)
+
+ except ImportError:
+ logging.error(f"Could not import module {path}")
+
+
+def query_mistral(prompt):
+ """Send a query to the Mistral model via Ollama API"""
+ ollama_host = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
+ response = requests.post(
+ f"{ollama_host}/api/generate",
+ json={
+ "model": "mistral",
+ "prompt": prompt,
+ "stream": False
+ }
+ )
+ return response.json()
+
+
+register_component("login", "components.login", "LoginApp")
+register_component("register", "components.register", "RegisterApp")
+register_component("theme_controller", "components.themecontroller", "ThemeControllerApp")
+register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
+register_component("tabs", "components.tabs", "TabsApp") # before repositories
+register_component("repositories", "components.repositories", "RepositoriesApp")
+register_component("add_stuff", "components.addstuff", None)
+register_component("form", "components.form", "FormApp")
+register_component("datagrid_new", "components.datagrid_new", "DataGridApp")
+register_component("debugger", "components.debugger", "DebuggerApp")
+
+routes.extend([
+ # old stuffs
Mount(f"/{BASIC_TEST_PATH}", basic_test_app, name="basic test"),
Mount(f"/{DATAGRID_PATH}", datagrid_app, name="datagrid"),
Mount(f"/{IMPORT_SETTINGS_PATH}", import_settings_app, name="import_settings"),
+])
+old_links = (
+ # daisy_ui_links_v4
+ Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
+ Script(src="./assets/tailwindcss.js"),
+
+ # datagridOld
+ Script(src="./components/datagrid/DataGrid.js"),
+ Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
+
+ # drw_layout_old
+ Script(src="./assets/DrawerLayout.js", defer=True),
+ Link(rel="stylesheet", href="./assets/DrawerLayout.css")
)
@@ -133,18 +206,14 @@ bware = Beforeware(before, skip=[r'/favicon\.ico',
app, rt = fast_app(
before=bware,
- hdrs=(daisy_ui_links,
- main_links, my_managing_tools_style,
- drawer_layout, addstuff,
- tabs, debugger, datagrid),
+ hdrs=tuple(links),
live=True,
- routes=routes,
+ routes=tuple(routes),
debug=True,
pico=False,
)
settings_manager = SettingsManager()
-settings_manager.init_user(NO_SESSION, NOT_LOGGED)
import_settings = AdminImportSettings(settings_manager, None)
pages = [
@@ -181,7 +250,7 @@ def get(session):
@rt("/test")
def get(session):
return (Title("Another Project Management"),
- datagridOld, drw_layout_old, daisy_ui_links_v4,
+ old_links,
Input(type='checkbox', value='light', cls='toggle theme-controller'),
DrawerLayoutOld(pages),)
@@ -219,4 +288,19 @@ def get(session):
return Titled("I like toast")
-serve(port=5001)
+if __name__ == "__main__":
+ # Start your application
+ print("Application starting...")
+
+ print("Checking if Mistral model is available...")
+ try:
+ requests.post(
+ f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/pull",
+ json={"name": "mistral"}
+ )
+ print("Mistral model is ready")
+ except Exception as e:
+ print(f"Error pulling Mistral model: {e}")
+
+
+ serve(port=5001)
diff --git a/src/pages/admin_import_settings.py b/src/pages/admin_import_settings.py
index 9b7074c..7a12ee0 100644
--- a/src/pages/admin_import_settings.py
+++ b/src/pages/admin_import_settings.py
@@ -11,7 +11,7 @@ from components.datagrid.DataGrid import DataGrid, DG_FILTER_INPUT, DG_TABLE_FOO
DG_DATATYPE_BOOL, DG_READ_ONLY
from components.datagrid.constants import DG_ROWS_INDEXES
from core.settings_management import SettingsManager
-from core.utils import make_html_id, make_column_id
+from core.utils import make_html_id, make_safe_id
ID_PREFIX = "import_settings"
@@ -152,7 +152,7 @@ class AdminImportSettings:
def get_columns_def(self, columns):
res = []
_mapping = {
- "Column Id": lambda c: make_column_id(str(c).strip()),
+ "Column Id": lambda c: make_safe_id(str(c).strip()),
"Column Header": lambda c: c,
"Amount": lambda c: False,
}
diff --git a/tests/helpers.py b/tests/helpers.py
index 3312356..3d7a777 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -8,7 +8,7 @@ import pandas as pd
from bs4 import BeautifulSoup
from fastcore.basics import NotStr
from fastcore.xml import to_xml
-from fasthtml.components import html2ft, Div
+from fasthtml.components import html2ft, Div, Span
pattern = r"""(?P\w+)(?:#(?P[\w-]+))?(?P(?:\[\w+=['"]?[\w_-]+['"]?\])*)"""
attr_pattern = r"""\[(?P\w+)=['"]?(?P[\w_-]+)['"]?\]"""
@@ -17,6 +17,7 @@ compiled_pattern = re.compile(pattern)
compiled_attr_pattern = re.compile(attr_pattern)
compiled_svg_pattern = re.compile(svg_pattern)
+
@dataclasses.dataclass
class DoNotCheck:
desc: str = None
@@ -33,6 +34,7 @@ class StartsWith:
"""
s: str
+
@dataclasses.dataclass
class Contains:
"""
@@ -40,6 +42,7 @@ class Contains:
"""
s: str
+
Empty = EmptyElement()
@@ -141,7 +144,10 @@ def search_elements_by_name(ft, tag: str = None, attrs: dict = None, comparison_
# Recursive case: search through the children
for child in _ft.children:
result.extend(_search_elements_by_name(child))
-
+ elif isinstance(_ft, (list, tuple)):
+ for _item in _ft:
+ result.extend(_search_elements_by_name(_item))
+
return result
if isinstance(ft, list):
@@ -156,7 +162,7 @@ def search_elements_by_name(ft, tag: str = None, attrs: dict = None, comparison_
def search_elements_by_path(ft, path: str, attrs: dict = None):
"""
Selects elements that match a given path. The path is a dot-separated list of elements.
- One the path if found, the optional attributes are compared against the last element's
+ Once the path if found, the optional attributes are compared against the last element's
attributes.
Note the path may not start at the root node of the tree structure.
@@ -188,10 +194,10 @@ def search_elements_by_path(ft, path: str, attrs: dict = None):
return _find(ft, "")
-def search_first_with_attribute(ft, tag, attribute
- ):
+def search_first_with_attribute(ft, tag, attribute):
"""
Browse ft and its children to find the first element that matches the tag and has the attribute defined
+ We do not care about the value of the attribute, just the presence of it.
if tag is None, it will return the first element with the attribute
:param ft:
:param tag:
@@ -266,6 +272,83 @@ def matches(actual, expected, path=""):
return type(x)
+ def _debug(_actual, _expected):
+ str_actual = _debug_print_actual(_actual, _expected, "", 3)
+ str_expected = _debug_print_expected(_expected, "", 2)
+ return f"\nactual={str_actual}\nexpected={str_expected}"
+
+ def _debug_value(x):
+ if x in ("** NOT FOUND **", "** NONE **", "** NO MORE CHILDREN **"):
+ return x
+ elif isinstance(x, str):
+ return f"'{x}'" if "'" not in x else f'"{x}"'
+ else:
+ return x
+
+ def _debug_print_actual(_actual, _expected, indent, max_level):
+ # debug print both actual and expected, showing only expected elements
+ if max_level == 0:
+ return ""
+
+ if _actual is None:
+ return f"{indent}** NONE **"
+
+ if not hasattr(_actual, "tag") or not hasattr(_expected, "tag"):
+ return f"{indent}{_actual}"
+
+ str_actual = f"{indent}({_actual.tag}"
+ first_attr = True
+ for attr in _expected.attrs:
+ comma = " " if first_attr else ", "
+ str_actual += f"{comma}{attr}={_debug_value(_actual.attrs.get(attr, '** NOT FOUND **'))}"
+ first_attr = False
+
+ if len(_expected.children) == 0 and len(_actual.children) and max_level > 1:
+ # force recursion to see sub levels
+ for _actual_child in _actual.children:
+ str_child_a = _debug_print_actual(_actual_child, _actual_child, indent + " ", max_level - 1)
+ str_actual += "\n" + str_child_a if str_child_a else ""
+
+ else:
+
+ for index, _expected_child in enumerate(_expected.children):
+ if len(_actual.children) > index:
+ _actual_child = _actual.children[index]
+ else:
+ _actual_child = "** NO MORE CHILDREN **"
+
+ str_child_a = _debug_print_actual(_actual_child, _expected_child, indent + " ", max_level - 1)
+ str_actual += "\n" + str_child_a if str_child_a else ""
+
+ str_actual += ")"
+
+ return str_actual
+
+ def _debug_print_expected(_expected, indent, max_level):
+ if max_level == 0:
+ return ""
+
+ if _expected is None:
+ return f"{indent}** NONE **"
+
+ if not hasattr(_expected, "tag"):
+ return f"{indent}{_expected}"
+
+ str_expected = f"{indent}({_expected.tag}"
+ first_attr = True
+ for attr in _expected.attrs:
+ comma = " " if first_attr else ", "
+ str_expected += f"{comma}{attr}={_expected.attrs[attr]}"
+ first_attr = False
+
+ for _expected_child in _expected.children:
+ str_child_e = _debug_print_expected(_expected_child, indent + " ", max_level - 1)
+ str_expected += "\n" + str_child_e if str_child_e else ""
+
+ str_expected += ")"
+
+ return str_expected
+
if actual is None and expected is not None:
assert False, f"{print_path(path)}actual is None !"
@@ -278,11 +361,11 @@ def matches(actual, expected, path=""):
return True
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
- f"{print_path(path)}The types are different: {type(actual)} != {type(expected)}, ({actual} != {expected})."
+ f"{print_path(path)}The types are different: {type(actual)} != {type(expected)}{_debug(actual, expected)}."
if isinstance(expected, (list, tuple)):
assert len(actual) >= len(expected), \
- f"{print_path(path)}Some required elements are missing: {actual} != {expected}."
+ f"{print_path(path)}Some required elements are missing: {len(actual)=} < {len(expected)}, \n{_debug(actual, expected)}."
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child)
@@ -329,7 +412,7 @@ def matches(actual, expected, path=""):
pass
else:
assert len(actual.children) >= len(expected.children), \
- f"{print_path(path)}Some required elements are missing: actual={actual.children} != expected={expected.children}."
+ f"{print_path(path)}Some required elements are missing: len(actual)={len(actual.children)} < len(expected)={len(expected.children)}{_debug(actual, expected)}."
for actual_child, expected_child in zip(actual.children, expected.children):
matches(actual_child, expected_child, path)
@@ -547,3 +630,11 @@ def icon(name: str):
def div_icon(name: str):
return Div(NotStr(f'