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:
36
src/components/BaseCommandManager.py
Normal file
36
src/components/BaseCommandManager.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
function bindRepositories(repositoryId) {
|
||||
bindTooltipsWithDelegation(repositoryId)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}` |
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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"
|
||||
111
src/components/datagrid_new/db_management.py
Normal file
111
src/components/datagrid_new/db_management.py
Normal 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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
72
src/components/debugger/assets/Debugger.css
Normal file
72
src/components/debugger/assets/Debugger.css
Normal 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);*/
|
||||
/*}*/
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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}"}}',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
286
src/components/debugger/components/JsonViewer.py
Normal file
286
src/components/debugger/components/JsonViewer.py
Normal 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}"
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
0
src/components/repositories/assets/__init__.py
Normal file
0
src/components/repositories/assets/__init__.py
Normal file
48
src/components/repositories/commands.py
Normal file
48
src/components/repositories/commands.py
Normal 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()}")}}',
|
||||
}
|
||||
@@ -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
|
||||
0
src/components/repositories/components/__init__.py
Normal file
0
src/components/repositories/components/__init__.py
Normal file
8
src/components/repositories/constants.py
Normal file
8
src/components/repositories/constants.py
Normal 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"
|
||||
185
src/components/repositories/db_management.py
Normal file
185
src/components/repositories/db_management.py
Normal 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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ import dataclasses
|
||||
|
||||
from core.settings_objects import BaseSettingObj
|
||||
|
||||
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeControllerSettings"
|
||||
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeController"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
||||
Reference in New Issue
Block a user