I can add tables

Refactoring DbEngine

Fixing unit tests

Fixing unit tests

Fixing unit tests

Refactored DbManager for datagrid

Improving front end performance

I can add new table

Fixed sidebar closing when clicking on it

Fix drag event rebinding, improve listener options, and add debug

Prevent duplicate drag event bindings with a dataset flag and ensure consistent scrollbar functionality. Change wheel event listener to passive mode for better performance. Refactor function naming for consistency, and add debug logs for event handling.

Refactor Datagrid bindings and default state handling.

Updated Javascript to conditionally rebind Datagrid on specific events. Improved Python components by handling empty DataFrame cases and removing redundant code. Revised default state initialization in settings for better handling of mutable fields.

Added Rowindex visualisation support

Working on Debugger with own implementation of JsonViewer

Working on JsonViewer.py

Fixed unit tests

Adding unit tests

I can fold and unfold

fixed unit tests

Adding css for debugger

Added tooltip management

Adding debugger functionalities

Refactor serializers and improve error handling in DB engine

Fixed error where tables were overwritten

I can display footer menu

Working on footer. Refactoring how heights are managed

Refactored scrollbars management

Working on footer menu

I can display footer menu + fixed unit tests

Fixed unit tests

Updated click management

I can display aggregations in footers

Added docker management

Refactor input handling and improve config defaults

Fixed scrollbars colors

Refactored tooltip management

Improved tooltip management

Improving FilterAll
This commit is contained in:
2025-05-11 18:27:32 +02:00
parent e1c10183eb
commit 66ea45f501
70 changed files with 2884 additions and 1258 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +0,0 @@
function bindRepositories(repositoryId) {
bindTooltipsWithDelegation(repositoryId)
}

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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])
}

View File

@@ -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)

View File

@@ -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}` |

View File

@@ -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%;

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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):

View File

@@ -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"),
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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"}

View File

@@ -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)

View File

@@ -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);*/
/*}*/

View File

@@ -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.

View File

@@ -7,3 +7,33 @@ icon_dbengine = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="h
</path>
</g>
</svg>""")
# Fluent CaretRight20Filled
icon_collapsed = NotStr("""<svg name="collapsed" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M7 14.204a1 1 0 0 0 1.628.778l4.723-3.815a1.5 1.5 0 0 0 0-2.334L8.628 5.02A1 1 0 0 0 7 5.797v8.407z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent CaretDown20Filled
icon_expanded = NotStr("""<svg name="expanded" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M5.797 7a1 1 0 0 0-.778 1.628l3.814 4.723a1.5 1.5 0 0 0 2.334 0l3.815-4.723A1 1 0 0 0 14.204 7H5.797z" fill="currentColor">
</path>
</g>
</svg>""")
icon_class = NotStr("""
<svg name="expanded" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="1.5" >
<polygon points="5,2 2,8 8,8" />
<rect x="12" y="2" width="6" height="6"/>
<circle cx="5" cy="15" r="3" />
<polygon points="11.5,15 15,11.5 18.5,15 15,18.5" />
</g>
</svg>
"""
)

View File

@@ -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}"}}',
}
"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}"}}',
}

View File

@@ -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,

View File

@@ -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}"

View File

@@ -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
DbEngineData = "/dbengine-data"
DbEngineRefs = "/dbengine-refs"
JsonViewerFold = "/jsonviewer-fold"
JsonOpenDigest = "/jsonviewer-open-digest"

View File

@@ -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 {

View File

@@ -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}"

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
)
)

View File

@@ -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))

View File

@@ -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()}")}}',
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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);

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -2,7 +2,7 @@ import dataclasses
from core.settings_objects import BaseSettingObj
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeControllerSettings"
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeController"
@dataclasses.dataclass