I can add tables

Refactoring DbEngine

Fixing unit tests

Fixing unit tests

Fixing unit tests

Refactored DbManager for datagrid

Improving front end performance

I can add new table

Fixed sidebar closing when clicking on it

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

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

Refactor Datagrid bindings and default state handling.

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

Added Rowindex visualisation support

Working on Debugger with own implementation of JsonViewer

Working on JsonViewer.py

Fixed unit tests

Adding unit tests

I can fold and unfold

fixed unit tests

Adding css for debugger

Added tooltip management

Adding debugger functionalities

Refactor serializers and improve error handling in DB engine

Fixed error where tables were overwritten

I can display footer menu

Working on footer. Refactoring how heights are managed

Refactored scrollbars management

Working on footer menu

I can display footer menu + fixed unit tests

Fixed unit tests

Updated click management

I can display aggregations in footers

Added docker management

Refactor input handling and improve config defaults

Fixed scrollbars colors

Refactored tooltip management

Improved tooltip management

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

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /app
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the source code
COPY src/ /app/src/
COPY src/logging.yaml /app/
# Set PYTHONPATH to include the src directory
ENV PYTHONPATH=/app/src
ENV ADMIN_EMAIL="admin"
ENV ADMIN_PASSWORD="admin"
# Command to run your application
# Adjust this to your actual entry point script
CMD ["python", "-m", "src.main"]

View File

@@ -7,4 +7,31 @@ using [FastHTML](https://www.fastht.ml/) to serve the front end
```commandline
cd src
python main.py
```
# Using Docker
1. **Build and start the services**:
```shell
docker-compose up -d
```
The application will be accessible on port 8000 (or whatever port you configured).
2. **Initialize the Mistral model** (first run):
```shell
docker-compose exec ollama ollama pull mistral:7b-instruct
```
1. **Check logs**:
```shell
docker-compose logs -f
```
1. **Stop the services**:
```shell
docker-compose down
```
1. **Rebuild**:
```shell
docker-compose build
```

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8001:5001"
volumes:
- ./src:/app/src
depends_on:
- ollama
environment:
- OLLAMA_HOST=http://ollama:11434
- PYTHONPATH=/app/src
networks:
- app-network
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
command: serve
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
ollama_data:

View File

@@ -31,4 +31,5 @@ watchfiles==0.24.0
websockets==13.1
pandas~=2.2.3
numpy~=2.1.1
numpy~=2.1.1
requests~=2.32.3

View File

@@ -1,62 +0,0 @@
from fasthtml.components import Style
my_managing_tools_style = Style("""
.icon-32 {
width: 32px;
height: 32px;
}
.icon-32 svg {
width: 100%;
height: 100%;
}
.icon-24 {
width: 24px;
min-width: 24px;
height: 24px;
}
.icon-24 svg {
width: 100%;
height: 100%;
}
.icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.icon-16 {
width: 16px;
min-width: 16px;
height: 16px;
margin-top: auto;
margin-bottom: 4px;
}
.icon-bool {
display: block;
width: 20px;
height: 20px;
margin: auto;
}
.icon-btn {
cursor: pointer;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-default {
cursor: default;
}
""")

View File

@@ -1,7 +1,11 @@
:root {
--theme-controller-zindex: 1000;
--datagrid-menu-zindex: 910;
--datagrid-sidebar-zindex: 900;
--datagrid-scrollbars-zindex: 800;
--mmt-tooltip-zindex: 10;
--datagrid-drag-drop-zindex: 5;
--datagrid-resize-zindex: 1;
}
.mmt-tooltip-container {
@@ -15,11 +19,86 @@
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: fixed; /* Keep it above other content and adjust position */
z-index: 10; /* Ensure it's on top */
z-index: var(--mmt-tooltip-zindex); /* Ensure it's on top */
}
.mmt-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
}
.icon-32 {
width: 32px;
height: 32px;
}
.icon-32 svg {
width: 100%;
height: 100%;
}
.icon-24 {
width: 24px;
min-width: 24px;
height: 24px;
}
.icon-24 svg {
width: 100%;
height: 100%;
}
.icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.icon-20-inline {
display: inline-block;
width: 20px;
min-width: 20px;
height: 20px;
padding-top: 4px;
}
.icon-16 {
width: 16px;
min-width: 16px;
height: 16px;
margin-top: auto;
margin-bottom: 4px;
}
.icon-16-inline {
display: inline-block;
width: 16px;
min-width: 16px;
height: 16px;
padding-top: 5px;
}
.icon-bool {
display: block;
width: 20px;
height: 20px;
margin: auto;
}
.icon-btn {
cursor: pointer;
}
.cursor-pointer {
cursor: pointer;
}
.cursor-default {
cursor: default;
}

View File

@@ -1,19 +1,31 @@
function bindTooltipsWithDelegation(elementId) {
const tooltipElementId = "mmt-app"
function bindTooltipsWithDelegation() {
const elementId = tooltipElementId
console.debug("bindTooltips on element " + elementId);
const element = document.getElementById(elementId);
const tooltipContainer = document.getElementById(`tt_${elementId}`);
if (!element || !tooltipContainer) {
console.error("Invalid element or tooltip container");
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
if (!tooltipContainer) {
console.error(`Invalid tooltip 'tt_${elementId}' container.`);
return;
}
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
const cell = event.target.closest("div[data-tooltip]");
const cell = event.target.closest("[data-tooltip]");
if (!cell) return;
const no_tooltip = element.hasAttribute("mmt-no-tooltip");
if (no_tooltip) return;
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains("mmt-tooltip");
@@ -34,18 +46,46 @@ function bindTooltipsWithDelegation(elementId) {
}
// Apply styles for tooltip positioning
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
requestAnimationFrame(() => {
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
});
}
}
}, true); // Use capture phase for better delegation if needed
element.addEventListener("mouseleave", (event) => {
const cell = event.target.closest("div[data-tooltip]");
const cell = event.target.closest("[data-tooltip]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Use capture phase for better delegation if needed
}
function disableTooltip() {
const elementId = tooltipElementId
// console.debug("disableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.setAttribute("mmt-no-tooltip", "");
}
function enableTooltip() {
const elementId = tooltipElementId
// console.debug("enableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.removeAttribute("mmt-no-tooltip");
}

View File

@@ -0,0 +1,36 @@
class BaseCommandManager:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
@staticmethod
def merge(*items):
"""
Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
If the key's value is not a string, an error is raised.
:param items: dictionaries to be merged. If all items are None, None is returned.
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
"""
if all(item is None for item in items):
return None
res = {}
for item in [item for item in items if item is not None]:
for key, value in item.items():
if not key in res:
res[key] = value
else:
if isinstance(res[key], str):
res[key] += " " + value
else:
raise NotImplementedError("")
return res
@staticmethod
def merge_class(cls1, cls2):
return (cls1 + " " + cls2) if cls2 else cls1

View File

@@ -10,6 +10,9 @@ class BaseComponent:
def get_id(self):
return self._id
def get_session(self):
return self._session
def __repr__(self):
return self._id

View File

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

View File

@@ -1,23 +0,0 @@
from components.addstuff.constants import ROUTE_ROOT, Routes
class Commands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def request_add_table(self, repository_name):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
}
def add_table(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
# The repository_name and the table_name will be given by the form
}

View File

@@ -1,26 +1,22 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ROUTE_ROOT, Routes
from components.addstuff.settings import RepositoriesDbManager
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
from components.repositories.components.Repositories import Repositories
from core.instance_manager import InstanceManager
class AddStuffMenu(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
self.tabs_manager = tabs_manager # MyTabs component id
self.mappings = {} # to keep track of when element is displayed on which tab
self.settings = RepositoriesDbManager(session, settings_manager)
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
def __ft__(self):
return Div(
Div("Add stuff...", tabindex="0"),
Ul(
Li(A("Add Database",
hx_get=f"{ROUTE_ROOT}{Routes.AddRepository}",
hx_target=f"#{self.tabs_manager.get_id()}",
hx_swap="outerHTML",
)),
Li(A("Add Database", **self.repositories.commands.request_add_repository())),
Li(A("Add Application")),
tabindex="0",
cls="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm"

View File

@@ -1,10 +1 @@
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
ADD_DATABASE_INSTANCE_ID = "__AddDatabase__"
REPOSITORIES_INSTANCE_ID = "__Repositories__"
ROUTE_ROOT = "/add"
class Routes:
AddRepository = "/add-repository"
SelectRepository = "/select-repository"
AddTable = "/add-table"
ShowTable = "/show-table"

View File

@@ -1,112 +0,0 @@
import dataclasses
import logging
from core.settings_management import SettingsManager
from core.settings_objects import BaseSettingObj
ADD_STUFF_SETTINGS_ENTRY = "AddStuffSettings"
REPOSITORIES_SETTINGS_ENTRY = "Repositories"
logger = logging.getLogger("AddStuffSettings")
@dataclasses.dataclass
class Repository:
name: str
tables: list[str]
@dataclasses.dataclass
class RepositoriesSettings:
repositories: list[Repository] = dataclasses.field(default_factory=list)
selected_repository_name: str = None
class RepositoriesDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self.session = session
self.settings_manager = settings_manager
def _get_settings(self):
return self.settings_manager.get(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
def add_repository(self, repository_name: str, tables: list[str] = None):
"""
Adds a new repository to the list of repositories. The repository is identified
by its name and can optionally include a list of associated tables.
:param repository_name: The name of the repository to be added.
:param tables: A list of Table objects to be associated with the repository,
defaulting to an empty list if not provided.
:type tables: list[Table], optional
:return: None
"""
settings = self._get_settings()
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
if repository_name in [repo.name for repo in settings.repositories]:
raise ValueError(f"Repository '{repository_name}' already exists.")
existing_repositories = [r.name for r in settings.repositories]
logger.info(f"Existing repositories:{existing_repositories}")
repository = Repository(repository_name, tables or [])
settings.repositories.append(repository)
self.settings_manager.put(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return repository
def get_repository(self, repository_name: str):
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
settings = self._get_settings()
if repository_name not in [repo.name for repo in settings.repositories]:
raise ValueError(f"Repository '{repository_name}' does not exists.")
return next(filter(lambda r: r.name == repository_name, settings.repositories))
def modify_repository(self, repository_name: str, tables: list[str]):
repository = self.get_repository(repository_name)
def get_repositories(self):
return self._get_settings().repositories
def add_table(self, repository_name: str, table_name: str, table_settings: dict):
"""
Adds a table to the specified repository
:param repository_name: The name of the target repository.
:param table_name: The name of the table to add.
:param table_settings: A dictionary containing the settings or configuration details
of the table.
:return: None
"""
settings = self._get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
raise ValueError(f"Repository '{repository_name}' does not exists.")
if table_name in (t.name for t in repository.tables):
raise ValueError(f"Table '{table_name}' already exists.")
repository.tables.append(MyTable(table_name, table_settings))
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
def select_repository(self, repository_name: str):
"""
Select and save the specified repository name in the current session's settings.
:param repository_name: The name of the repository to be selected and stored.
:type repository_name: str
:return: None
"""
settings = self._get_settings()
settings.selected_repository_name = repository_name
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)

View File

@@ -11,7 +11,7 @@ from components.datagrid.DataGridCommandManager import DataGridCommandManager
from components.datagrid.constants import *
from components.datagrid.icons import *
from core.utils import append_once, from_bool
from core.utils import make_html_id, make_column_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
from core.utils import make_html_id, make_safe_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
logger = logging.getLogger("DataGrid")
@@ -1697,16 +1697,16 @@ class DataGrid:
if (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is not None:
res[DG_COLUMNS] = {
make_column_id(col): {INDEX_KEY: index,
TITLE_KEY: col,
make_safe_id(col): {INDEX_KEY: index,
TITLE_KEY: col,
"type": dtype_mapping.get(dtype.name, DG_DATATYPE_STRING), }
for index, (col, dtype) in enumerate(zip(df.columns, df.dtypes))
}
elif (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is None and columns is not None:
res[DG_COLUMNS] = {
make_column_id(col): {INDEX_KEY: index,
TITLE_KEY: snake_case_to_capitalized_words(col)}
make_safe_id(col): {INDEX_KEY: index,
TITLE_KEY: snake_case_to_capitalized_words(col)}
for index, col in enumerate(columns)
}
@@ -1717,7 +1717,7 @@ class DataGrid:
if isinstance(res[DG_COLUMNS], list):
# set grid settings from list
res[DG_COLUMNS] = {
make_column_id(col): {INDEX_KEY: index, TITLE_KEY: col}
make_safe_id(col): {INDEX_KEY: index, TITLE_KEY: col}
for index, col in enumerate(res[DG_COLUMNS])
}

View File

@@ -125,7 +125,13 @@ def post(session, _id: str, key: str, arg: str = None):
@rt(Routes.OnClick)
def post(session, _id: str, cell_id: str = None, modifier: str = None):
logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}")
def post(session, _id: str, cell_id: str = None, modifier: str = None, boundaries: str=None):
logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}, {boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.manage_click(cell_id, modifier)
return instance.manage_click(cell_id, modifier, json.loads(boundaries) if boundaries else None)
@rt(Routes.UpdateState)
def post(session, _id: str, state: str, args: str = None):
logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}")
instance = InstanceManager.get(session, _id)
return instance.manage_state_changed(state, args)

View File

@@ -4,16 +4,18 @@
using `Datagrid(id=my_id)`
| Name | value |
|--------------------------------|----------------------------------------------------------------|
| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
| filter all | `fa_{datagrid_id}` |
| file upload | `fu_{datagrid_id}` |
| sidebar | `sb_{datagrid_id}` |
| scroll bars | `scb_{datagrid_id}` |
| Settings columns | `scol_{datagrid_id}` |
| table | `t_{datagrid_id}` |
| table cell drop down | `tcdd_{datagrid_id}` |
| table drag and drop info | `tdd_{datagrid_id}` |
| views selection component | `v_{datagrid_id}` |
| Name | value |
|---------------------------|----------------------------------------------------------------|
| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
| filter all | `fa_{datagrid_id}` |
| file upload | `fu_{datagrid_id}` |
| footer menu | `fm_{datagrid_id}` |
| sidebar | `sb_{datagrid_id}` |
| scroll bars | `scb_{datagrid_id}` |
| Settings columns | `scol_{datagrid_id}` |
| table | `t_{datagrid_id}` |
| table container | `tc_{datagrid_id}` |
| table cell menu | `tcm_{datagrid_id}` |
| table drag and drop info | `tdd_{datagrid_id}` |
| views selection component | `v_{datagrid_id}` |

View File

@@ -6,7 +6,7 @@ input:focus {
display: none;
position: absolute;
top: 100%;
z-index: 5;
z-index: var(--datagrid-drag-drop-zindex);
width: 100px;
border: 1px solid var(--color-base-300);
border-radius: 10px;
@@ -20,8 +20,8 @@ input:focus {
}
.dt2-main {
position: relative;
height: 100%;
position: relative;
}
.dt2-sidebar {
@@ -46,80 +46,77 @@ input:focus {
transition: opacity 0.3s ease;
}
.dt2-container {
position: relative;
}
.dt2-scrollbars {
bottom: 0;
left: 0;
pointer-events: none; /* Ensures parents don't intercept pointer events */
position: absolute;
top: 24px;
bottom: 0px;
left: 0;
right: 0;
top: 0;
pointer-events: none; /* Ensures parents don't intercept pointer events */
z-index: var(--datagrid-scrollbars-zindex);
}
/* Scrollbar Wrappers */
.dt2-scrollbars-vertical-wrapper {
bottom: 3px;
left: auto;
position: absolute;
right: 3px;
top: 3px;
width: 8px;
background-color: var(--color-base-200)
}
.dt2-scrollbars-horizontal-wrapper {
bottom: -12px;
height: 8px;
left: 3px;
position: absolute;
right: 3px;
top: auto;
background-color: var(--color-base-200)
}
/* Vertical Scrollbar */
.dt2-scrollbars-vertical {
bottom: auto;
left: 0;
position: absolute;
right: 0;
top: auto;
background-color: var(--color-base-300);
border-radius: 3px; /* Rounded corners */
pointer-events: auto; /* Enable interaction */
cursor: pointer;
width: 100%; /* Fits inside its wrapper */
}
/* Horizontal Scrollbar */
.dt2-scrollbars-horizontal {
bottom: 0;
left: auto;
position: absolute;
right: auto;
top: 0;
background-color: var(--color-base-300);
border-radius: 3px; /* Rounded corners */
pointer-events: auto; /* Enable interaction */
cursor: pointer;
height: 100%; /* Fits inside its wrapper */
}
/* Scrollbar wrappers are hidden by default */
/* Scrollbar Wrappers common attributes*/
.dt2-scrollbars-vertical-wrapper,
.dt2-scrollbars-horizontal-wrapper {
position: absolute;
background-color: var(--color-base-200)
opacity: 1;
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
pointer-events: auto; /* Allow interaction */
}
/* Scrollbar Wrappers */
.dt2-scrollbars-vertical-wrapper {
left: auto;
right: 3px;
top: 3px;
bottom: 3px;
width: 8px;
}
.dt2-scrollbars-horizontal-wrapper {
left: 3px;
right: 3px;
top: auto;
bottom: -12px;
height: 8px;
}
/* Scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: var(--color-resize);
background-color: var(--color-base-300);
border-radius: 3px;
pointer-events: auto; /* Allow interaction with the scrollbar */
cursor: pointer;
position: absolute;
border-radius: 3px; /* Rounded corners */
pointer-events: auto; /* Enable interaction */
cursor: pointer;
}
/* Vertical Scrollbar */
.dt2-scrollbars-vertical {
left: 0;
right: 0;
top: auto;
bottom: auto;
width: 100%; /* Fits inside its wrapper */
}
/* Horizontal Scrollbar */
.dt2-scrollbars-horizontal {
left: auto;
right: auto;
top: 0;
bottom: 0;
height: 100%; /* Fits inside its wrapper */
}
/* Scrollbar hover effects */
@@ -130,7 +127,6 @@ input:focus {
background-color: var(--color-base-content);
}
.dt2-table {
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
@@ -153,7 +149,6 @@ input:focus {
}
.dt2-body {
max-height: 650px;
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
font-size: 14px;
min-width: max-content;
@@ -200,6 +195,36 @@ input:focus {
padding-right: 10px;
}
.dt2-footer-cell {
cursor : pointer
}
.dt2-footer-menu {
position: absolute;
display: None;
z-index: var(--datagrid-menu-zindex);
border: 1px solid oklch(var(--b3));
box-sizing: border-box;
width: 80px;
background-color: var(--color-base-100); /* Add background color */
opacity: 1; /* Ensure full opacity */
}
.dt2-footer-menu.show {
display: block;
}
.dt2-footer-menu-item {
padding : 0 8px;
border-radius: 4px;
background-color: var(--color-base-100); /* Add background color */
}
.dt2-footer-menu-item:hover {
background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
cursor : pointer
}
.dt2-resize-handle {
position: absolute;
right: 0;
@@ -212,7 +237,7 @@ input:focus {
.dt2-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
z-index: var(--datagrid-resize-zindex);
display: block; /* Makes it a block element */
width: 3px;
height: 60%;

View File

@@ -1,5 +1,4 @@
function bindDatagrid(datagridId, allowColumnsReordering) {
bindTooltipsWithDelegation(datagridId);
bindScrollbars(datagridId);
makeResizable(datagridId)
}
@@ -26,16 +25,16 @@ function bindScrollbars(datagridId) {
return;
}
let scrollingTimeout;
const computeScrollbarVisibility = () => {
// Determine if the content is clipped
const isVerticalRequired = body.scrollHeight > body.clientHeight;
const isHorizontalRequired = table.scrollWidth > table.clientWidth;
// Show or hide the scrollbar wrappers
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
requestAnimationFrame(() => {
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
});
};
const computeScrollbarSize = () => {
@@ -44,11 +43,9 @@ function bindScrollbars(datagridId) {
const totalHeight = body.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
let scrollbarHeight = 0;
if (totalHeight > 0) {
const scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
verticalScrollbar.style.height = `${scrollbarHeight}px`;
} else {
verticalScrollbar.style.height = `0px`;
scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
}
// Horizontal scrollbar width
@@ -56,15 +53,18 @@ function bindScrollbars(datagridId) {
const totalWidth = table.scrollWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
let scrollbarWidth = 0;
if (totalWidth > 0) {
const scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
} else {
horizontalScrollbar.style.width = `0px`;
scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
}
requestAnimationFrame(() => {
verticalScrollbar.style.height = `${scrollbarHeight}px`;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
});
};
const updateVerticalScrollbarPosition = () => {
const updateVerticalScrollbarForMouseWheel = () => {
const maxScrollTop = body.scrollHeight - body.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
@@ -75,11 +75,16 @@ function bindScrollbars(datagridId) {
};
const addDragEvent = (scrollbar, updateFunction) => {
if (scrollbar.dataset.dragEventsBound === "true") {
return; // Events are already bound to this scrollbar, so skip binding again
}
let isDragging = false;
let startY = 0;
let startX = 0;
scrollbar.addEventListener("mousedown", (e) => {
disableTooltip();
isDragging = true;
startY = e.clientY;
startX = e.clientX;
@@ -104,20 +109,23 @@ function bindScrollbars(datagridId) {
isDragging = false;
document.body.style.userSelect = ""; // Re-enable text selection
scrollbar.classList.remove("dt2-dragging");
enableTooltip();
});
// Set a flag to indicate events are bound
scrollbar.dataset.dragEventsBound = "true";
};
const updateVerticalScrollbar = (deltaX, deltaY) => {
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = body.scrollHeight - body.clientHeight;
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
verticalScrollbar.style.top = `${newTop}px`;
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
body.scrollTop = newTop * scrollRatio;
};
@@ -125,13 +133,12 @@ function bindScrollbars(datagridId) {
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
const maxScrollLeft = table.scrollWidth - table.clientWidth;
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
horizontalScrollbar.style.left = `${newLeft}px`;
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
table.scrollLeft = newLeft * scrollRatio;
};
@@ -144,7 +151,7 @@ function bindScrollbars(datagridId) {
table.scrollLeft += deltaX; // Horizontal scrolling
// Update the vertical scrollbar position
updateVerticalScrollbarPosition();
updateVerticalScrollbarForMouseWheel();
// Prevent default behavior to fully manage the scroll
@@ -154,7 +161,7 @@ function bindScrollbars(datagridId) {
addDragEvent(verticalScrollbar, updateVerticalScrollbar);
addDragEvent(horizontalScrollbar, updateHorizontalScrollbar);
body.addEventListener("wheel", handleWheelScrolling);
body.addEventListener("wheel", handleWheelScrolling, {passive: false});
// Initialize scrollbars
computeScrollbarVisibility();
@@ -164,7 +171,7 @@ function bindScrollbars(datagridId) {
window.addEventListener("resize", () => {
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarPosition();
updateVerticalScrollbarForMouseWheel();
});
}
@@ -229,7 +236,6 @@ function makeResizable(datagridId) {
const {colIndex, startWidth, cells} = resizingState;
const finalWidth = cells[0].offsetWidth;
console.debug(`Column ${colIndex} resized from ${startWidth}px to ${finalWidth}px`);
// Emit custom event (server communication can be tied here)
const resizeEvent = new CustomEvent('columnResize', {
@@ -263,6 +269,9 @@ function makeResizable(datagridId) {
}
function bindColumnsSettings(datagridId) {
/**
* To change the order of the rows
*/
console.debug("bindColumnsSettings on element " + datagridId);
const datagrid = document.querySelector(`#${datagridId}`);
if (!datagrid) {
@@ -354,23 +363,80 @@ function getColumnsDefinitions(columnsSettingsId) {
}
function getCellId(event) {
/*
Find the id of the dt-body-cell
*/
function findParentByName(element, name) {
let parent = element;
while (parent) {
if (parent.getAttribute('name') === name) {
return parent;
}
parent = parent.parentElement;
}
return null; // Return null if no matching parent is found
// Find element with data-col attribute
const elementForColId = event.target.closest('[data-col]');
if (!elementForColId) {
return null; // Return null if we can't find a column element
}
const colId = elementForColId.getAttribute('data-col');
// Check if element is in header
const headerElement = event.target.closest('.dt2-header');
if (headerElement) {
return "header|" + colId + "|0";
}
const parentElement = findParentByName(event.target, 'dt-body-cell')
return parentElement ? parentElement.id : null;
// Try to find row ID from data-row attribute
const elementForRowId = event.target.closest('[data-row]');
if (!elementForRowId) {
return null; // Return null if we can't find a row element
}
const rowId = elementForRowId.getAttribute('data-row');
// Get parent element to determine if in body or footer
const elementForTablePart = elementForRowId.parentElement;
// Determine table part based on class
let tablePart = null;
if (elementForTablePart) {
if (elementForTablePart.classList.contains("dt2-body")) {
tablePart = "body";
} else if (elementForTablePart.classList.contains("dt2-footer")) {
tablePart = "footer";
}
}
// Return formatted cell identifier or null if essential data is missing
if (colId && rowId && tablePart) {
return tablePart + "|" + colId + "|" + rowId;
}
return null;
}
function getCellBoundaries(event) {
// Get the target element from the click event
const target = event.target;
// Find the closest parent with the class 'dt2-cell'
const cellElement = target.closest('.dt2-cell');
if (!cellElement) {
console.warn('No parent element with class "dt2-cell" found.');
return null;
}
// Get the nearest ancestor with relative/absolute positioning
const positionedAncestor = cellElement.offsetParent;
// Get the bounding rectangle of the cell
const cellRect = cellElement.getBoundingClientRect();
// Determine the ancestor's position for relative adjustment
const ancestorRect = positionedAncestor.getBoundingClientRect()
// Calculate x and y positions relative to the positioned ancestor
const x = cellRect.left - ancestorRect.left; // Relative to the ancestor
const y = cellRect.top - ancestorRect.top; // Relative to the ancestor
return {
x,
y,
height: cellRect.height,
width: cellRect.width,
};
}
function getClickModifier(event) {
@@ -381,15 +447,49 @@ function getClickModifier(event) {
const isAltGr = event.ctrlKey && event.altKey && !event.shiftKey && event.code === "AltRight";
if (!isAltGr) {
if (event.altKey) { res += "alt-" }
if (event.ctrlKey) { res += "ctrl-" }
if (event.altKey) {
res += "alt-"
}
if (event.ctrlKey) {
res += "ctrl-"
}
} else {
res += "altgr-"; // Special case for AltGr
}
if (event.metaKey) { res += "meta-" }
if (event.shiftKey) { res += "shift-" }
if (event.metaKey) {
res += "meta-"
}
if (event.shiftKey) {
res += "shift-"
}
return res;
}
return null;
}
function validateOnClickRequest(datagridId, event) {
if (event.target.id !== datagridId) { // only try to cancel event sent by the datagrid
return;
}
const triggeringElt = event.detail.requestConfig.triggeringEvent.target
const sidebar = triggeringElt.closest('.dt2-sidebar');
if (sidebar) {
event.preventDefault();
}
}
function onAfterSettle(datagridId, event) {
const datagrid = document.getElementById(datagridId);
if (!datagrid) {
console.error(`Datagrid with ID "${datagridId}" not found.`);
return;
}
// Only rebind if the table is resend
const response = event.detail.xhr.responseText;
if (response.includes("hx-on::before-settle")) {
bindDatagrid(datagridId)
}
}

View File

@@ -1,7 +1,7 @@
import copy
import logging
from io import BytesIO
from typing import Literal
from typing import Literal, Any
import pandas as pd
from fasthtml.components import *
@@ -15,47 +15,94 @@ from components.datagrid_new.components.FileUpload import FileUpload
from components.datagrid_new.components.FilterAll import FilterAll
from components.datagrid_new.components.Views import Views
from components.datagrid_new.components.commands import DataGridCommandManager
from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ColumnType, FILTER_INPUT_CID, \
ViewType
from components.datagrid_new.settings import DataGridDatabaseManager, DataGridRowState, DataGridColumnState, \
from components.datagrid_new.constants import *
from components.datagrid_new.db_management import DataGridDbManager
from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_column_id
from core.utils import get_unique_id, make_safe_id
logger = logging.getLogger("DataGrid")
class DataGrid(BaseComponent):
def __init__(self, session, _id: str = None, key: str = None, settings_manager=None):
"""
DataGrid component that provides a rich, interactive table with various features
like filtering, column management, and view management.
"""
def __init__(self, session, _id: str = None, key: Any = None, settings_manager=None, boundaries=None):
"""
Initialize the DataGrid component.
Args:
session: The current session
_id: Component ID
key: Optional key for persistence
settings_manager: Optional settings manager
"""
super().__init__(session, _id)
self.commands = DataGridCommandManager(self)
self._key = key
self._settings_manager = settings_manager
self._db = DataGridDatabaseManager(session, settings_manager, key)
self._db = DataGridDbManager(session, settings_manager, key)
# Load state and data
self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe()
# update boundaries if possible
self.set_boundaries(boundaries)
# Create child components
self._file_upload = self._create_component(FileUpload, f"fu_{self._id}")
self._filter_all = self._create_component(FilterAll, f"fa_{self._id}")
self._columns_settings = self._create_component(ColumnsSettings, f"scol_{self._id}")
self._views = self._create_component(Views, f"v_{self._id}")
# init
# Initial setup
self.close_sidebar()
# First time updates
if len(self._state.footers) == 0:
self._state.footers.append(DataGridFooterConf())
# ----------------------
# Initialization Methods
# ----------------------
def init_from_excel(self):
df = pd.read_excel(BytesIO(self._file_upload.file_content),
sheet_name=self._file_upload.selected_sheet_name)
"""
Initialize the DataGrid from an Excel file uploaded by the user.
Returns:
self: For method chaining
"""
df = pd.read_excel(
BytesIO(self._file_upload.file_content),
sheet_name=self._file_upload.selected_sheet_name
)
self._settings.file_name = self._file_upload.file_name
self._settings.selected_sheet_name = self._file_upload.selected_sheet_name
self._db.save_settings(self._settings)
return self.init_from_dataframe(df)
return self.init_from_dataframe(df, save_state=True)
def init_from_dataframe(self, df: DataFrame):
def init_from_dataframe(self, df: DataFrame, save_state=False):
"""
Initialize the DataGrid from a pandas DataFrame.
Args:
df: Source DataFrame
save_state: Whether to save state to database
Returns:
self: For method chaining
"""
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
@@ -69,14 +116,17 @@ class DataGrid(BaseComponent):
return ColumnType.Text # Default to Text if no match
self._df = df.copy()
self._df.columns = self._df.columns.map(make_column_id) # make sure column names are trimmed
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = [DataGridColumnState(make_column_id(col_id),
self._state.columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_column_id(col_id)].dtype))
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)]
self._db.save_all(None, self._state, self._df)
if save_state:
self._db.save_all(None, self._state, self._df)
return self
def update_columns_state(self, updates: list[dict] | None = None, mode: Literal["delta", "replace"] = "delta"):
@@ -131,7 +181,7 @@ class DataGrid(BaseComponent):
new_column = False
if updates is None:
return self.mk_table()
return self.mk_table_container()
if mode == "delta":
for update in updates:
@@ -156,7 +206,7 @@ class DataGrid(BaseComponent):
self._db.save_all(self._settings, self._state, self._df if new_column else None)
return self.mk_table(), self.close_sidebar(), self._views.render_select_view(oob=True)
return self.mk_table_container(), self.close_sidebar(), self._views.render_select_view(oob=True)
def add_view(self, view_name, columns: list[DataGridColumnState]):
if view_name in [v.name for v in self._settings.views]:
@@ -168,7 +218,7 @@ class DataGrid(BaseComponent):
self._db.save_all(settings=self._settings, state=self._state)
return self.mk_table()
return self.mk_table_container()
def update_view(self, view_name, columns: list[DataGridColumnState]):
view = self.get_view(view_name)
@@ -179,7 +229,7 @@ class DataGrid(BaseComponent):
self._db.save_settings(self._settings)
return self.mk_table()
return self.mk_table_container()
def change_view(self, view_name):
@@ -189,7 +239,7 @@ class DataGrid(BaseComponent):
self._state.selected_view = view_name
return self.mk_table()
return self.mk_table_container()
def filter(self, column_id: str, filtering_values: str | list[str]):
"""
@@ -204,7 +254,7 @@ class DataGrid(BaseComponent):
else:
self._state.filtered[column_id] = filtering_values
return self.mk_table()
return self.mk_table_container()
def finalize_interaction(self, new_pos=None, new_input_element=None):
res = []
@@ -214,6 +264,9 @@ class DataGrid(BaseComponent):
self._state.sidebar_visible = False
res.append(self.mk_sidebar(None, self._state.sidebar_visible, oob=True))
# reset footer menu
res.append(self.mk_footer_menu(None, oob=True))
# manage the selection
select_manager = self.mk_selection_manager(new_pos)
res.append(select_manager)
@@ -258,10 +311,33 @@ class DataGrid(BaseComponent):
new_pos = self.navigate(None) # create the cursor if it was the first time
return self.finalize_interaction(new_pos)
def manage_click(self, col_index, row_index, modifier=''):
def manage_click(self, cell_id, modifier, boundaries):
try:
source, col_id, row_index = cell_id.split("|")
if source == "footer":
return self.show_footer_menu(col_id, int(row_index), boundaries)
except ValueError:
pass
new_pos = self.escape()
return self.finalize_interaction(new_pos)
def manage_state_changed(self, state, args):
if state == DATAGRID_STATE_FOOTER:
col_id, row_index, agg_value = args.split("|")
footer_conf = self._state.footers[int(row_index)]
footer_conf.conf[col_id] = agg_value
self._db.save_state(self._state)
col_def = self._get_col_def(col_id)
footer_conf = self._state.footers[int(row_index)]
return (self.mk_table_footer_cell(col_def, int(row_index), footer_conf, True),
self.mk_footer_menu(None, oob=True))
return self.mk_table()
def show_footer_menu(self, col_id: str, row_index: int, boundaries: dict):
return self.mk_footer_menu(col_id, row_index, boundaries)
def get_state(self) -> DataGridState:
return self._state
@@ -271,14 +347,30 @@ class DataGrid(BaseComponent):
def get_table_id(self):
return f"t_{self._id}"
def get_view(self, view_name: str = None) -> DatagridView | None:
def get_view(self, view_name: str | None = None) -> DatagridView | None:
if view_name is None:
view_name = self._state.selected_view
if view_name is None: # if self._state.selected_view is None
return None
try:
return next(view for view in self._settings.views if view.name == view_name)
except StopIteration:
return None
raise ValueError(f"View '{view_name}' does not exist.")
def set_boundaries(self, boundaries):
if boundaries is not None:
self._state.boundaries = {CONTAINER_WIDTH: boundaries['width'],
CONTAINER_HEIGHT: boundaries['height']}
def mk_table_container(self):
return Div(
self.mk_scrollbars(),
self.mk_table(),
cls="dt2-container",
id=f"tc_{self._id}"
)
def mk_scrollbars(self):
return Div(
@@ -290,7 +382,7 @@ class DataGrid(BaseComponent):
def mk_table(self, oob=False):
htmx_extra_params = {
"hx-on::after-settle": f"bindDatagrid('{self._id}', true);",
"hx-on::before-settle": f"onAfterSettle('{self._id}', event);",
# "hx-on::before-request": "onCellEdition(event);",
}
@@ -331,18 +423,15 @@ class DataGrid(BaseComponent):
hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
name="dt2-kbm",
),
if self._df is None:
return Div(id=f"t_{self._id}")
return Div(
self.mk_selection_manager(),
Div(Label(mk_icon(icon_move), cls="flex gap-2"), id=f"tdd_{self._id}", cls="dt2-drag-drop"),
Div(id=f"tcdd_{self._id}"),
self.mk_footer_menu(None),
_mk_keyboard_management(),
Div(
self.mk_scrollbars(),
self.mk_table_header(),
self.mk_table_body(),
self.mk_table_footer(),
@@ -386,6 +475,7 @@ class DataGrid(BaseComponent):
def mk_table_body(self):
df = self._get_filtered_df()
max_height = self._compute_body_max_height()
return Div(
*[Div(
@@ -395,34 +485,18 @@ class DataGrid(BaseComponent):
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index],
cls="dt2-body",
style=f"max-height:{max_height}px;",
id=f"tb_{self._id}"
)
def mk_table_footer(self):
def _mk_footer(footer: DataGridFooterConf, col_def: DataGridColumnState):
if not col_def.usable:
return None
if not col_def.visible:
return Div(cls="dt2-col-hidden")
if col_def.col_id in footer.conf:
value = "Found !"
else:
value = "very long Footer"
return Div(mk_ellipsis(value),
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell ",
)
return Div(
*[Div(
*[_mk_footer(footer, col_def) for col_def in self._state.columns],
*[self.mk_table_footer_cell(col_def, row_index, footer) for col_def in self._state.columns],
id=f"tf_{self._id}",
data_row=f"{row_index}",
cls="dt2-row dt2-row-footer",
) for footer in self._state.footers or [DataGridFooterConf()]],
) for row_index, footer in enumerate(self._state.footers)],
cls="dt2-footer",
id=f"tf_{self._id}"
)
@@ -434,17 +508,14 @@ class DataGrid(BaseComponent):
if not col_def.visible:
return Div(cls="dt2-col-hidden")
content, extra_cls = self.mk_body_cell_content(col_pos, row_index, col_def)
cls_to_use = "dt2-cell" + (f" {extra_cls}" if extra_cls else "")
content = self.mk_body_cell_content(col_pos, row_index, col_def)
return Div(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls=cls_to_use)
cls="dt2-cell")
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
cls = ""
content = ""
def mk_bool(value):
return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False),
@@ -473,17 +544,80 @@ class DataGrid(BaseComponent):
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
return tuple(res)
if col_def.visible:
column_type = col_def.type
if column_type == ColumnType.Bool:
content = mk_bool(self._df.iloc[row_index, col_def.col_index])
elif column_type == ColumnType.Number:
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
else:
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
column_type = col_def.type
return content, cls
if column_type == ColumnType.Bool:
content = mk_bool(self._df.iloc[row_index, col_def.col_index])
elif column_type == ColumnType.Number:
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
elif column_type == ColumnType.RowIndex:
content = mk_number(row_index)
else:
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
return content
def mk_table_footer_cell(self, col_def, row_index: int, footer_conf, oob=False):
"""
Generates a footer cell for a data table based on the provided column definition,
row index, footer configuration, and optional out-of-bound setting. This method
applies appropriate aggregation functions, determines visibility, and structures
the cell's elements accordingly.
:param col_def: Details of the column state, including its usability, visibility,
and column ID, which are necessary to determine how the footer
cell should be created.
:type col_def: DataGridColumnState
:param row_index: The specific index of the footer row where this cell will be
added. This parameter is used to uniquely identify the cell
within the footer.
:type row_index: int
:param footer_conf: Configuration for the footer that contains mapping of column
IDs to their corresponding aggregation functions. This is
critical for calculating aggregated values for the cell content.
:type footer_conf: DataGridFooterConf
:param oob: A boolean flag indicating whether the configuration involves any
out-of-bound parameters that must be handled specifically. This
parameter is optional and defaults to False.
:type oob: bool
:return: Returns an instance of `Div`, containing the visually structured footer
cell content, including the calculated aggregation if applicable. If
the column is not usable, it returns None. For non-visible columns, it
returns a hidden cell `Div`. The aggregation value is displayed for valid
aggregations. If none is applicable or the configuration is invalid,
appropriate default content or styling is applied.
:rtype: Div | None
"""
if not col_def.usable:
return None
if not col_def.visible:
return Div(cls="dt2-col-hidden")
if col_def.col_id in footer_conf.conf:
agg_function = footer_conf.conf[col_def.col_id]
if agg_function == FooterAggregation.Sum.value:
value = self._df[col_def.col_id].sum()
elif agg_function == FooterAggregation.Min.value:
value = self._df[col_def.col_id].min()
elif agg_function == FooterAggregation.Max.value:
value = self._df[col_def.col_id].max()
elif agg_function == FooterAggregation.Mean.value:
value = self._df[col_def.col_id].mean()
elif agg_function == FooterAggregation.Count.value:
value = self._df[col_def.col_id].count()
else:
value = "** Invalid aggregation function **"
else:
value = None
return Div(mk_ellipsis(value, cls="dt2-cell-content-number"),
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls="dt2-cell dt2-footer-cell",
id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
hx_swap_oob='true' if oob else None,
)
def mk_menu(self, oob=False):
return Div(
@@ -550,6 +684,38 @@ class DataGrid(BaseComponent):
return select_manager
def mk_footer_menu(self, col_id: str | None, row_index: int = None, cell_boundaries: dict = None, oob=False):
def _compute_footer_menu_height(_cell_boundaries: dict):
y = _cell_boundaries['y'] # position of the footer cell that was clicked
footer_height = _cell_boundaries['height'] # position of the footer cell that was clicked
table_max_height = self._state.boundaries[CONTAINER_HEIGHT]
menu_bottom = y + footer_height + len(FooterAggregation) * 21 + 16
return y + footer_height \
if menu_bottom < table_max_height \
else y - footer_height - len(FooterAggregation) * 21 + 16
if col_id is None:
return Div(cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm ",
id=f"tcm_{self._id}",
hx_swap_oob='true' if oob else None,
)
if col_id:
assert cell_boundaries is not None, "boundaries must be provided if col_id is provided"
menu_top = _compute_footer_menu_height(cell_boundaries)
return Div(
*[Div(
mk_ellipsis(agg.value),
**self.commands.update_footer_aggregation(col_id, row_index, agg.value, cls="dt2-footer-menu-item"),
) for agg in FooterAggregation],
cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm show",
style=f"left:{cell_boundaries['x'] + 10}px;top:{menu_top}px;",
id=f"tcm_{self._id}",
hx_swap_oob='true' if oob else None,
)
def toggle_sidebar(self, content):
logger.debug(f"toggle sidebar {self._id}. Previous state: {self._state.sidebar_visible}")
self._state.sidebar_visible = not self._state.sidebar_visible
@@ -569,7 +735,7 @@ class DataGrid(BaseComponent):
def _get_filtered_df(self):
if self._df is None:
return None
return DataFrame()
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
@@ -577,6 +743,9 @@ class DataGrid(BaseComponent):
return df
def _get_col_def(self, col_id: str):
return next((col_def for col_def in self._state.columns if col_def.col_id == col_id), None)
def _apply_sort(self, df):
if df is None:
return None
@@ -613,21 +782,37 @@ class DataGrid(BaseComponent):
component_type,
owner=self)
def _compute_body_max_height(self):
filter_height = 40
header_height = 24
footer_height = 24
h_scrollbar_height = 25
to_subtract = filter_height + header_height + footer_height + h_scrollbar_height + 5
max_height = self._state.boundaries.get(CONTAINER_HEIGHT, DATAGRID_MAX_HEIGHT) \
if self._state.boundaries \
else DATAGRID_MAX_HEIGHT
max_height -= to_subtract
return max_height
def __ft__(self):
return Div(
mk_tooltip_container(self._id),
Div(Div(self._filter_all, self._views, cls="flex"),
self.mk_menu(), cls="flex justify-between"),
Div(
self.mk_table(),
self.mk_sidebar(None, self._state.sidebar_visible),
cls="dt2-main",
Div(Div(self._filter_all, self._views, cls="flex"),
self.mk_menu(), cls="flex justify-between"),
Div(
self.mk_table_container(),
self.mk_sidebar(None, self._state.sidebar_visible),
cls="dt2-main",
),
Script(f"bindDatagrid('{self._id}', false);"),
**self.commands.on_click()
),
Script(f"bindDatagrid('{self._id}', false);"),
id=f"{self._id}",
**self.commands.on_click()
id=f"{self._id}"
)
@staticmethod

View File

@@ -4,6 +4,7 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
from components.datagrid_new.components.commands import FilterAllCommands
from components.datagrid_new.constants import Routes, ROUTE_ROOT, FILTER_INPUT_CID, DATAGRID_INSTANCE_ID
from core.utils import get_unique_id
@@ -21,6 +22,7 @@ class FilterAll(BaseComponent, ):
:param datagrid:
"""
super().__init__(session, _id)
self.commands = FilterAllCommands(self)
self._owner = owner
logger.debug(f"FilterAll component created with id: {self._id}")
@@ -46,11 +48,13 @@ class FilterAll(BaseComponent, ):
Input(name='f',
placeholder="Filter...",
value=value,
#**self.commands.filter_all(),
hx_post=f"{ROUTE_ROOT}{Routes.Filter}",
hx_trigger="keyup changed throttle:300ms",
hx_trigger="keyup",
hx_target=f"#t_{self._owner.get_id()}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
),
cls="input input-sm flex gap-2"
),
id=f"fi_{self._id}", # fa stands for 'filter all'
@@ -59,12 +63,17 @@ class FilterAll(BaseComponent, ):
def _mk_reset_button(self):
return Div(icon_dismiss_regular,
cls="icon-24 my-auto icon-btn ml-2",
hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
hx_trigger="click",
hx_target=f"#t_{self._owner.get_id()}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
#cls="icon-24 my-auto icon-btn ml-2",
**self.commands.reset_filter_all(cls="icon-24 my-auto icon-btn ml-2"),
# hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
# hx_trigger="click",
# hx_target=f"#t_{self._owner.get_id()}",
# hx_swap="outerHTML",
# hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
),
def get_datagrid(self):
return self._owner
@staticmethod
def create_component_id(session, prefix=None, suffix=None):

View File

@@ -1,12 +1,12 @@
import json
from components.datagrid_new.constants import ROUTE_ROOT, Routes
from components.BaseCommandManager import BaseCommandManager
from components.datagrid_new.constants import ROUTE_ROOT, Routes, DATAGRID_STATE_FOOTER, FILTER_INPUT_CID
class DataGridCommandManager:
def __init__(self, datagrid):
self.datagrid = datagrid
self._id = self.datagrid.get_id()
class DataGridCommandManager(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
def cancel(self):
return {
@@ -53,7 +53,7 @@ class DataGridCommandManager:
def update_columns_settings(self, component):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
"hx-target": f"#{self.datagrid.get_table_id()}", # table
"hx-target": f"#{self._owner.get_table_id()}", # table
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "updates": getColumnsDefinitions("{component.get_id()}")}}',
}
@@ -64,21 +64,37 @@ class DataGridCommandManager:
def show_columns(self, col_defs: list, cls=""):
return self._get_hide_show_columns_attrs("Show", col_defs, "true", cls=cls)
def reset_filters(self, cls=""):
return {"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
"hx_vals": f'{{"g_id": "{self._id}"}}',
"hx_target": f"#t_{self._id}",
"hx_swap": "outerHTML",
"data_tooltip": "Reset all filters",
"cls": self.merge_class(cls, "dt-tooltip")}
def show_footer_menu(self, col_id, cls=""):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.ShowFooterMenu}",
"hx_target": f"#tcm_{self._id}",
"hx_swap": "outerHTML",
"hx-trigger": "click consume",
"hx_vals": f'js:{{"_id": "{self._id}", "col_id": "{col_id}", "boundaries": getCellBoundaries(event)}}',
"data_tooltip": "Change aggregation",
"cls": self.merge_class(cls, "mmt-tooltip")
}
def update_footer_aggregation(self, col_id, row_index, agg_type, cls=""):
args = f"{col_id}|{row_index}|{agg_type}"
return {
"hx_post": f"{ROUTE_ROOT}{Routes.UpdateState}",
"hx_target": f"#t_{self._id}",
"hx_swap": "none",
"hx_vals": f'{{"_id": "{self._id}", "state": "{DATAGRID_STATE_FOOTER}", "args": "{args}"}}',
"hx-trigger": "click consume",
# "data_tooltip": f"Change footer aggregation to {agg_type}",
"cls": self.merge_class(cls, "mmt-tooltip")
}
def on_click(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
"hx-target": f"#tsm_{self._id}",
"hx-trigger"
"hx-trigger" : "click",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event)}}',
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}',
"hx-on::before-request": f'validateOnClickRequest("{self._id}", event)',
}
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
@@ -93,35 +109,60 @@ class DataGridCommandManager:
"data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "mmt-tooltip")
}
@staticmethod
def merge(*items):
"""
Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
If the key's value is not a string, an error is raised.
#
# @staticmethod
# def merge(*items):
# """
# Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
# If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
# If the key's value is not a string, an error is raised.
#
# :param items: dictionaries to be merged. If all items are None, None is returned.
# :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
# :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
# """
# if all(item is None for item in items):
# return None
#
# res = {}
# for item in [item for item in items if item is not None]:
#
# for key, value in item.items():
# if not key in res:
# res[key] = value
# else:
# if isinstance(res[key], str):
# res[key] += " " + value
# else:
# raise NotImplementedError("")
#
# return res
#
# @staticmethod
# def merge_class(cls1, cls2):
# return (cls1 + " " + cls2) if cls2 else cls1
:param items: dictionaries to be merged. If all items are None, None is returned.
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
"""
if all(item is None for item in items):
return None
res = {}
for item in [item for item in items if item is not None]:
for key, value in item.items():
if not key in res:
res[key] = value
else:
if isinstance(res[key], str):
res[key] += " " + value
else:
raise NotImplementedError("")
return res
class FilterAllCommands(BaseCommandManager):
def __init__(self, owner):
super().__init__(owner)
@staticmethod
def merge_class(cls1, cls2):
return (cls1 + " " + cls2) if cls2 else cls1
def filter_all(self):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.Filter}",
"hx_trigger": "keyup changed consume",
"hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
"hx_swap": "outerHTML",
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
}
def reset_filter_all(self, cls=""):
return {
"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
"hx_trigger": "click consume",
"hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
"hx_swap": "outerHTML",
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
"data_tooltip": "Reset filter",
"cls": self.merge_class(cls, "mmt-tooltip"),
}

View File

@@ -6,6 +6,18 @@ FILTER_INPUT_CID = "__filter_input__"
DEFAULT_COLUMN_WIDTH = 100
ADD_NEW_VIEW = "__add_new_view__"
DATAGRID_DB_ENTRY = "Datagrid"
DATAGRID_DB_SETTINGS_ENTRY = "Settings"
DATAGRID_DB_STATE_ENTRY = "State"
DATAGRID_DB_DATAFRAME_ENTRY = "Dataframe"
DATAGRID_MAX_HEIGHT = 300
CONTAINER_WIDTH = "container_width"
CONTAINER_HEIGHT = "container_height"
DATAGRID_STATE_FOOTER = "footer"
class Routes:
Filter = "/filter" # request the filtering in the grid
ResetFilter = "/reset_filter" #
@@ -19,6 +31,8 @@ class Routes:
ChangeView = "/change_view"
AddView = "/add_view"
UpdateView = "/update_view"
ShowFooterMenu = "/show_footer_menu"
UpdateState = "/update_state"
class ColumnType(Enum):
@@ -34,3 +48,15 @@ class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"
class FooterAggregation(Enum):
Sum = "Sum"
Mean = "Mean"
Min = "Min"
Max = "Max"
Count = "Count"
FilteredSum = "FilteredSum"
FilteredMean = "FilteredMean"
FilteredMin = "FilteredMin"
FilteredMax = "FilteredMax"
FilteredCount = "FilteredCount"

View File

@@ -0,0 +1,111 @@
from pandas import DataFrame
from components.datagrid_new.constants import DATAGRID_DB_ENTRY, DATAGRID_DB_SETTINGS_ENTRY, DATAGRID_DB_STATE_ENTRY, \
DATAGRID_DB_DATAFRAME_ENTRY
from components.datagrid_new.settings import DataGridSettings, DataGridState
from core.settings_management import SettingsManager
from core.utils import make_safe_id
class DataframeWrapper:
def __init__(self, df: DataFrame):
self.df = df
def __eq__(self, other):
if isinstance(other, DataframeWrapper):
return self.df.equals(other.df)
else:
return False
def __hash__(self):
return hash(self.df.to_json())
@staticmethod
def use_refs():
return {"df"}
class DataGridDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
self._session = session
self._settings_manager = settings_manager
self._key = "#".join(make_safe_id(item) for item in key) if key else ""
# init the db if needed
if self._settings_manager and not self._settings_manager.exists(self._session, self._get_db_entry()):
self._settings_manager.save(self._session, self._get_db_entry(), {})
def _get_db_entry(self):
return f"{DATAGRID_DB_ENTRY}_{self._key}"
def save_settings(self, settings: DataGridSettings):
if self._settings_manager is None:
return
self._settings_manager.put(self._session,
self._get_db_entry(),
DATAGRID_DB_SETTINGS_ENTRY,
settings)
def save_state(self, state: DataGridState):
if self._settings_manager is None:
return
self._settings_manager.put(self._session,
self._get_db_entry(),
DATAGRID_DB_STATE_ENTRY,
state)
def save_dataframe(self, df: DataFrame):
if self._settings_manager is None:
return
self._settings_manager.put(self._session,
self._get_db_entry(),
DATAGRID_DB_DATAFRAME_ENTRY,
DataframeWrapper(df))
def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
if self._settings_manager is None:
return
items = {}
if settings is not None:
items[DATAGRID_DB_SETTINGS_ENTRY] = settings
if state is not None:
items[DATAGRID_DB_STATE_ENTRY] = state
if df is not None:
items[DATAGRID_DB_DATAFRAME_ENTRY] = DataframeWrapper(df)
self._settings_manager.put_many(self._session, self._get_db_entry(), items)
def load_settings(self):
if self._settings_manager is None:
return DataGridSettings()
return self._settings_manager.get(self._session,
self._get_db_entry(),
DATAGRID_DB_SETTINGS_ENTRY,
default=DataGridSettings())
def load_state(self):
if self._settings_manager is None:
return DataGridState()
return self._settings_manager.get(self._session,
self._get_db_entry(),
DATAGRID_DB_STATE_ENTRY,
default=DataGridState())
def load_dataframe(self):
if self._settings_manager is None:
return None
wrapper = self._settings_manager.get(self._session,
self._get_db_entry(),
DATAGRID_DB_DATAFRAME_ENTRY,
default=None)
if wrapper is None:
return None
return wrapper.df

View File

@@ -1,15 +1,6 @@
import dataclasses
import json
from io import StringIO
import pandas as pd
from pandas import DataFrame
from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType, ROUTE_ROOT, Routes
from core.settings_management import SettingsManager, SettingsTransaction
from core.utils import make_column_id
DATAGRID_SETTINGS_ENTRY = "DatagridSettings"
from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
@dataclasses.dataclass
@@ -48,7 +39,7 @@ class DatagridSelectionState:
@dataclasses.dataclass
class DataGridFooterConf:
conf: dict[str, str] = dataclasses.field(default_factory=dict)
conf: dict[str, str] = dataclasses.field(default_factory=dict) # first 'str' is the column id
@dataclasses.dataclass
@@ -64,65 +55,25 @@ class DataGridSettings:
selected_sheet_name: str = None
header_visible: bool = True
views: list[DatagridView] = dataclasses.field(default_factory=list)
@staticmethod
def use_refs():
return {"views"}
@dataclasses.dataclass
class DataGridState:
sidebar_visible: bool = False
selected_view: str = None
columns: list[DataGridColumnState] = None
rows: list[DataGridRowState] = None # only the rows that have a specific state
columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)
sorted: list = dataclasses.field(default_factory=list)
filtered: dict = dataclasses.field(default_factory=dict)
edition: DatagridEditionState = dataclasses.field(default_factory=DatagridEditionState)
selection: DatagridSelectionState = dataclasses.field(default_factory=DatagridSelectionState)
class DataGridDatabaseManager:
def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
self._session = session
self._settings_manager = settings_manager
self._key = "#".join(make_column_id(item) for item in key)
boundaries: dict = None # {height; width} that gives the maximum space possible
def save_settings(self, settings: DataGridSettings):
self._settings_manager.put(self._session, self.get_settings_entry(), settings)
def save_state(self, state: DataGridState):
self._settings_manager.put(self._session, self.get_state_entry(), state)
def save_dataframe(self, df: DataFrame):
self._settings_manager.put(self._session, self.get_data_entry(), df.to_json())
def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
with SettingsTransaction(self._session, self._settings_manager) as st:
if settings is not None:
st.put(self.get_settings_entry(), settings)
if state is not None:
st.put(self.get_state_entry(), state)
if df is not None:
st.put(self.get_data_entry(), df.to_json())
def load_settings(self):
return self._settings_manager.get(self._session, self.get_settings_entry(), default=DataGridSettings())
def load_state(self):
return self._settings_manager.get(self._session, self.get_state_entry(), default=DataGridState())
def load_dataframe(self):
as_json = self._settings_manager.get(self._session, self.get_data_entry(), default=None)
if as_json is None:
return None
df = pd.read_json(StringIO(as_json))
return df
def get_settings_entry(self):
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_settings"
def get_state_entry(self):
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_state"
def get_data_entry(self):
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_data"
@staticmethod
def use_refs():
return {"columns", "rows", "footers"}

View File

@@ -3,15 +3,30 @@ import logging
from fasthtml.fastapp import fast_app
from components.debugger.constants import Routes
from core.instance_manager import InstanceManager
from core.instance_manager import InstanceManager, debug_session
debugger_app, rt = fast_app()
logger = logging.getLogger("Debugger")
@rt(Routes.DbEngine)
def post(session, _id: str, digest: str = None):
logger.debug(f"Entering {Routes.DbEngine} with args {_id=}, {digest=}")
@rt(Routes.DbEngineData)
def post(session, _id: str, user_id: str, digest: str = None):
logger.debug(f"Entering {Routes.DbEngineData} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
return instance.add_tab(digest)
return instance.add_tab(user_id, digest)
@rt(Routes.JsonViewerFold)
def post(session, _id: str, node_id: str, folding: str):
logger.debug(f"Entering {Routes.JsonViewerFold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
instance = InstanceManager.get(session, _id)
instance.set_node_folding(node_id, folding)
return instance.render_node(node_id)
@rt(Routes.JsonOpenDigest)
def post(session, _id: str, user_id: str, digest: str):
logger.debug(f"Entering {Routes.JsonOpenDigest} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
instance = InstanceManager.get(session, _id)
return instance.open_digest(user_id, digest)

View File

@@ -0,0 +1,72 @@
:root:has(input.theme-controller[value=light]:checked),
[data-theme="light"] {
--json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
--json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
--json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
--json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
--json-null: var(--color-base-content);
--json-digest: var(--color-base-content);
}
:root:has(input.theme-controller[value=dark]:checked),
[data-theme="dark"] {
--json-bool: oklch(88.5% 0.062 18.334); /* tailwindcss orange-200 */
--json-string: oklch(92.5% 0.084 155.995); /* tailwindcss green-200 */
--json-number: oklch(88.2% 0.059 254.128); /* tailwindcss blue-200 */
--json-object: oklch(44.4% 0.177 26.899); /* tailwindcss red-800 */
--json-null: var(--color-base-content);
--json-digest: var(--color-base-content);
}
:root:has(input.theme-controller[value=cupcake]:checked),
[data-theme="cupcake"] {
--json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
--json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
--json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
--json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
--json-null: var(--color-base-content); /* tailwindcss violet-400 */
--json-digest: var(--color-base-content);
}
.mmt-jsonviewer {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
}
/* Use inherited CSS variables for your custom theme */
.mmt-jsonviewer-bool {
color: var(--json-bool);
}
.mmt-jsonviewer-string {
color: var(--json-string);
}
.mmt-jsonviewer-number {
color: var(--json-number);
}
.mmt-jsonviewer-null {
color: var(--json-null);
}
.mmt-jsonviewer-digest {
color: var(--json-digest);
cursor: pointer;
}
.mmt-jsonviewer-object {
color: var(--json-object);
}
/*:root:has(input.theme-controller[value=dark]:checked),*/
/*[data-theme="dark"] {*/
/* --json-bool: oklch(40.8% 0.123 38.172); !* tailwindcss orange-900 *!*/
/* --json-string: oklch(39.3% 0.095 152.535); !* tailwindcss green-900 *!*/
/* --json-number: oklch(37.9% 0.146 265.522); !* tailwindcss blue-900 *!*/
/* --json-null: var(--color-base-content);*/
/* --json-digest: var(--color-base-content);*/
/*}*/

View File

@@ -1,5 +1,5 @@
// Import the svelte-jsoneditor module
import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
// import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
/**
* Initializes and displays a JSON editor using the Svelte JSON Editor.

View File

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

View File

@@ -6,11 +6,40 @@ class Commands:
self._owner = owner
self._id = owner.get_id()
def show_dbengine(self):
def db_engine_data(self, user_id: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngine}",
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineData}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}"}}',
}
def db_engine_refs(self, ref_id: str | None):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineRefs}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "ref_id": "{ref_id}"}}',
}
class JsonViewerCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def fold(self, node_id: str, folding: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.JsonViewerFold}",
"hx-target": f"#{node_id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
}
def open_digest(self, user_id, digest):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.JsonOpenDigest}",
"hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
}

View File

@@ -1,4 +1,3 @@
import json
import logging
from fasthtml.components import *
@@ -6,13 +5,15 @@ from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.debugger.assets.icons import icon_dbengine
from components.debugger.commands import Commands
from components.debugger.components.DbEngineDebugger import DbEngineDebugger
from components.debugger.components.JsonViewer import JsonViewer
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
from components_helpers import mk_ellipsis, mk_icon
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
logger = logging.getLogger("Debugger")
class Debugger(BaseComponent):
def __init__(self, session, _id, settings_manager, tabs_manager):
super().__init__(session, _id)
@@ -21,26 +22,51 @@ class Debugger(BaseComponent):
self.tabs_manager = tabs_manager
self.commands = Commands(self)
def add_tab(self, digest):
content = self.mk_db_engine(digest)
def add_tab(self, user_id, digest):
content = self.mk_db_engine_object(user_id, digest)
tab_key = f"debugger-dbengine-{digest}"
title = f"DBEngine-{digest if digest else 'head'}"
self.tabs_manager.add_tab(title, content, key=tab_key)
return self.tabs_manager.render()
def mk_db_engine(self, digest):
data = self.db_engine.debug_load(digest) if digest else self.db_engine.debug_head()
def mk_db_engine_object(self, user_id, digest):
data = self.db_engine.debug_load(user_id, digest) if digest else self.db_engine.debug_head(user_id)
logger.debug(f"mk_db_engine: {data}")
return DbEngineDebugger(self._session, self._id, self, json.dumps(data))
return InstanceManager.get(self._session,
JsonViewer.create_component_id(self._session, prefix=self._id),
JsonViewer,
owner=self,
user_id=user_id,
data=data)
def mk_db_engine(self, selected):
return Div(
Input(type="radio",
name=f"dbengine-accordion-{self._id}",
checked="checked" if selected else None,
cls="p-0! min-h-0!",
),
Div(
mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine", cls="text-sm"),
cls="collapse-title p-0 min-h-0 flex truncate",
),
Div(
*[Div(user_id, **self.commands.db_engine_data(user_id)) for user_id in self.db_engine.debug_users()],
Div("refs", **self.commands.db_engine_refs(None)),
cls="collapse-content pr-0! truncate",
),
cls="collapse mb-2",
id=f"db_engine_{self._id}",
)
def __ft__(self):
return Div(
Div(cls="divider"),
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
Div(
mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine"),
self.mk_db_engine(True),
cls="flex truncate",
**self.commands.show_dbengine(),
),
id=self._id,

View File

@@ -0,0 +1,286 @@
import dataclasses
from typing import Any
from fasthtml.components import *
from pandas import DataFrame
from components.BaseComponent import BaseComponent
from components.datagrid_new.components.DataGrid import DataGrid
from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.debugger.commands import JsonViewerCommands
from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
from core.serializer import TAG_OBJECT
from core.utils import get_unique_id
class FoldingMode:
COLLAPSE = "collapse"
EXPAND = "expand"
@dataclasses.dataclass
class Node:
value: Any
@dataclasses.dataclass
class ValueNode(Node):
hint: str = None
@dataclasses.dataclass
class ListNode(Node):
node_id: str
level: int
children: list[Node] = dataclasses.field(default_factory=list)
@dataclasses.dataclass
class DictNode(Node):
node_id: str
level: int
children: dict[str, Node] = dataclasses.field(default_factory=dict)
class JsonViewer(BaseComponent):
def __init__(self, session, _id, owner, user_id, data):
super().__init__(session, _id)
self._owner = owner # debugger component
self.user_id = user_id
self.data = data
self._node_id = -1
self._commands = JsonViewerCommands(self)
# A little explanation on how the folding / unfolding work
# all the nodes are either fold or unfold... except if there are not
# self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
# self._nodes_to_track keeps track of the exceptions
# The idea is to minimize the memory usage
self._folding_mode = FoldingMode.COLLAPSE
self._nodes_to_track = set() # all nodes that are expanded when _fold_mode and vice versa
self._nodes_by_id = {}
self.node = self._create_node(None, data)
def set_node_folding(self, node_id, folding):
if folding == self._folding_mode:
self._nodes_to_track.remove(node_id)
else:
self._nodes_to_track.add(node_id)
def render_node(self, node_id):
key, node = self._nodes_by_id[node_id]
return self._render_node(key, node)
def set_folding_mode(self, folding_mode):
self._folding_mode = folding_mode
self._nodes_to_track.clear()
def get_folding_mode(self):
return self._folding_mode
def get_owner(self):
return self._owner
def open_digest(self, user_id: str, digest: str):
return self._owner.add_tab(user_id, digest)
def _create_node(self, key, data, level=0):
if isinstance(data, list):
node_id = self._get_next_id()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self._nodes_to_track.add(node_id)
node = ListNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
for index, item in enumerate(data):
node.children.append(self._create_node(index, item, level + 1))
elif isinstance(data, dict):
node_id = self._get_next_id()
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
self._nodes_to_track.add(node_id)
node = DictNode(data, node_id, level)
self._nodes_by_id[node_id] = (key, node)
for key, value in data.items():
node.children[key] = self._create_node(key, value, level + 1)
else:
if key == TAG_OBJECT:
hint = NODE_OBJECT
else:
hint = None
node = ValueNode(data, hint)
return node
def _must_expand(self, node):
if self._folding_mode == FoldingMode.COLLAPSE:
return node.node_id in self._nodes_to_track
else:
return node.node_id not in self._nodes_to_track
def _mk_folding(self, node: Node):
if not isinstance(node, (ListNode, DictNode)):
return None
must_expand = self._must_expand(node)
return Span(icon_expanded if must_expand else icon_collapsed,
cls="icon-16-inline mmt-jsonviewer-folding",
style=f"margin-left: -{INDENT_SIZE}px;",
**self._commands.fold(node.node_id, FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND)
)
def _get_next_id(self):
self._node_id += 1
return f"{self._id}-{self._node_id}"
def _render_value(self, node):
def _is_sha256(_value):
return isinstance(_value, str) and len(_value) == 64 and all(
c in '0123456789abcdefABCDEF' for c in _value)
if isinstance(node, DictNode):
return self._render_dict(node)
elif isinstance(node, ListNode):
return self._render_list(node)
else:
data_tooltip = None
htmx_params = {}
icon = None
if isinstance(node.value, bool): # order is important bool is an int in Python !
str_value = "true" if node.value else "false"
data_class = "bool"
elif isinstance(node.value, (int, float)):
str_value = str(node.value)
data_class = "number"
elif node.value is None:
str_value = "null"
data_class = "null"
elif _is_sha256(node.value):
str_value = str(node.value)
data_class = "digest"
htmx_params = self._commands.open_digest(self.user_id, node.value)
elif node.hint == NODE_OBJECT:
icon = icon_class
str_value = node.value.split(".")[-1]
data_class = "object"
elif isinstance(node.value, DataFrame):
dg = DataGrid(self._session)
dg.init_from_dataframe(node.value)
str_value = dg
data_class = "dataframe"
else:
as_str = str(node.value)
if len(as_str) > MAX_TEXT_LENGTH:
str_value = as_str[:MAX_TEXT_LENGTH] + "..."
data_tooltip = as_str
else:
str_value = as_str
str_value = self.add_quotes(str_value)
data_class = "string"
if data_tooltip is not None:
cls = f"mmt-jsonviewer-{data_class} mmt-tooltip"
else:
cls = f"mmt-jsonviewer-{data_class}"
if icon is not None:
return Span(Span(icon, cls="icon-16-inline mr-1"),
Span(str_value, data_tooltip=data_tooltip, **htmx_params),
cls=cls)
return Span(str_value, cls=cls, data_tooltip=data_tooltip, **htmx_params)
def _render_dict(self, node: DictNode):
if self._must_expand(node):
return Span("{",
*[
self._render_node(key, value)
for key, value in node.children.items()
],
Div("}"),
id=node.node_id)
else:
return Span("{...}", id=node.node_id)
def _render_list(self, node: ListNode):
def _all_the_same(_node):
if len(_node.children) == 0:
return False
sample_value = _node.children[0].value
if sample_value is None:
return False
type_ = type(sample_value)
if type_ in (int, float, str, bool, list, dict, ValueNode):
return False
return all(type(item.value) == type_ for item in _node.children)
def _render_as_grid(_node):
type_ = type(_node.children[0].value)
icon = icon_class
str_value = type_.__name__.split(".")[-1]
data = [child.value.__dict__ for child in _node.children]
df = DataFrame(data)
dg = DataGrid(self._session)
dg.init_from_dataframe(df)
return Span(Span(Span(icon, cls="icon-16-inline mr-1"), Span(str_value), cls="mmt-jsonviewer-object"),
dg,
id=_node.node_id)
def _render_as_list(_node):
return Span("[",
*[
self._render_node(index, item)
for index, item in enumerate(_node.children)
],
Div("]"),
)
if self._must_expand(node):
if _all_the_same(node):
return _render_as_grid(node)
return _render_as_list(node)
else:
return Span("[...]", id=node.node_id)
def _render_node(self, key, node):
return Div(
self._mk_folding(node),
Span(f'{key} : ') if key is not None else None,
self._render_value(node),
style=f"margin-left: {INDENT_SIZE}px;",
id=node.node_id if hasattr(node, "node_id") else None,
)
def __ft__(self):
return Div(
Div(self._render_node(None, self.node), id=f"{self._id}-root"),
cls="mmt-jsonviewer",
id=f"{self._id}")
@staticmethod
def add_quotes(value: str):
if '"' in value and "'" in value:
# Value contains both double and single quotes, escape double quotes
return f'"{value.replace("\"", "\\\"")}"'
elif '"' in value:
# Value contains double quotes, use single quotes
return f"'{value}'"
else:
# Default case, use double quotes
return f'"{value}"'
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if suffix is None:
suffix = get_unique_id()
return f"{prefix}{suffix}"

View File

@@ -1,6 +1,14 @@
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
ROUTE_ROOT = "/debugger"
INDENT_SIZE = 20
MAX_TEXT_LENGTH = 50
NODE_OBJECT = "Object"
NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
class Routes:
DbEngine = "/dbengine" # request the filtering in the grid
DbEngineData = "/dbengine-data"
DbEngineRefs = "/dbengine-refs"
JsonViewerFold = "/jsonviewer-fold"
JsonOpenDigest = "/jsonviewer-open-digest"

View File

@@ -12,22 +12,35 @@ main {
}
.dl-main {
flex-grow: 1; /* Ensures it grows to fill available space */
height: 100%; /* Inherit height from its parent */
overflow-x: auto;
display: flex;
flex-direction: column; /* Stack children vertically */
flex-grow: 1;
height: 100%;
overflow-x: auto;
}
.dl-main label {
align-self: flex-start; /* Aligns the label to the left */
}
.dl-main:focus {
outline: none;
outline: none;
}
.dl-page {
flex: 1;
}
.dl-sidebar {
position: relative;
width: 150px;
flex-shrink: 0; /* Prevent sidebar from shrinking */
flex-grow: 0; /* Disable growth (optional for better control) */
transition: width 0.2s ease;
height: 100%; /* Makes the sidebar height span the entire viewport */
position: relative;
width: 150px;
flex-shrink: 0; /* Prevent sidebar from shrinking */
flex-grow: 0; /* Disable growth (optional for better control) */
transition: width 0.2s ease;
height: 100%; /* Makes the sidebar height span the entire viewport */
}
.dl-sidebar.collapsed {

View File

@@ -3,10 +3,10 @@ from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.addstuff.components.AddStuffMenu import AddStuffMenu
from components.addstuff.components.Repositories import Repositories
from components.debugger.components.Debugger import Debugger
from components.drawerlayout.assets.icons import icon_panel_contract_regular, icon_panel_expand_regular
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
from components.repositories.components.Repositories import Repositories
from components.tabs.components.MyTabs import MyTabs
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
@@ -18,22 +18,11 @@ class DrawerLayout(BaseComponent):
_id: str = None,
settings_manager: SettingsManager = None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self._tabs = InstanceManager.get(session, MyTabs.create_component_id(session), MyTabs)
self._add_stuff = InstanceManager.get(session,
AddStuffMenu.create_component_id(session),
AddStuffMenu,
settings_manager=settings_manager,
tabs_manager=self._tabs)
self._repositories = InstanceManager.get(session,
Repositories.create_component_id(session),
Repositories,
settings_manager=settings_manager,
tabs_manager=self._tabs)
self._debugger = InstanceManager.get(session,
Debugger.create_component_id(session),
Debugger,
settings_manager=settings_manager,
tabs_manager=self._tabs)
self._repositories = self._create_component(Repositories)
self._debugger = self._create_component(Debugger)
self._add_stuff = self._create_component(AddStuffMenu)
def __ft__(self):
return Div(
@@ -57,13 +46,21 @@ class DrawerLayout(BaseComponent):
cls="swap",
),
Div(self._tabs, id=f"page_{self._id}", name="page"),
Div(self._tabs, id=f"page_{self._id}", name="page", cls='dl-page'),
cls='dl-main',
tabindex="0",
),
cls="dl-container flex"
), Script(f"bindDrawerLayout('{self._id}')")
def _create_component(self, component_type: type):
safe_create_component_id = getattr(component_type, "create_component_id")
return InstanceManager.get(self._session,
safe_create_component_id(self._session),
component_type,
settings_manager=self._settings_manager,
tabs_manager=self._tabs)
@staticmethod
def create_component_id(session, suffix: str = ""):
return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"

View File

@@ -54,10 +54,5 @@ def post(session, email: str, password: str):
# Log in user by setting session data
AuthManager.login_user(session, user_data)
# Make sure that the settings are created for this user
user_id = user_data["id"]
user_email = user_data["email"]
instance.init_user(user_id, user_email)
# Redirect to home page
return RedirectResponse('/', status_code=303)

View File

@@ -26,9 +26,6 @@ class Login:
self.error_message = error_message
return self.__ft__()
def init_user(self, user_id: str, user_email: str):
return self.settings_manager.init_user(user_id, user_email)
def __ft__(self):
# Create alert for error or success message
message_alert = None

View File

@@ -1,8 +1,10 @@
from fasthtml.components import *
from fasthtml.xtend import Script
import config
from components.footer import footer
from components.header.components.MyHeader import MyHeader
from components_helpers import mk_tooltip_container
def page_layout_new(session, settings_manager, content):
@@ -24,9 +26,12 @@ def page_layout_lite(session, settings_manager, content):
return (
Title(f"{config.APP_NAME}"),
Div(
mk_tooltip_container("mmt-app"),
MyHeader(session, settings_manager),
Main(content, cls="flex-grow"),
footer(),
cls="flex flex-col min-h-screen"
Script("bindTooltipsWithDelegation();"),
cls="flex flex-col min-h-screen",
id="mmt-app",
)
)

View File

@@ -1,14 +1,15 @@
import json
import logging
from fasthtml.fastapp import fast_app
from components.addstuff.components.Repositories import Repositories
from components.addstuff.constants import Routes
from components.repositories.components.Repositories import Repositories
from components.repositories.constants import Routes
from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("AddStuffApp")
add_stuff_app, rt = fast_app()
repositories_app, rt = fast_app()
@rt(Routes.AddRepository)
@@ -19,11 +20,11 @@ def get(session):
@rt(Routes.AddRepository)
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str):
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries:str):
logger.debug(
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}")
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id) # Repository
return instance.add_new_repository(tab_id, form_id, repository, table)
return instance.add_new_repository(tab_id, form_id, repository, table, json.loads(tab_boundaries))
@rt(Routes.AddTable)
@@ -33,9 +34,10 @@ def get(session, _id: str, repository_name: str):
@rt(Routes.AddTable)
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str):
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries:str):
logger.debug(f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.add_new_table(tab_id, form_id, repository_name, table_name)
return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries))
@rt(Routes.SelectRepository)
@@ -46,7 +48,7 @@ def put(session, _id: str, repository: str):
@rt(Routes.ShowTable)
def get(session, _id: str, repository: str, table: str):
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}")
def get(session, _id: str, repository: str, table: str, tab_boundaries:str):
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_table(repository, table)
return instance.show_table(repository, table, json.loads(tab_boundaries))

View File

@@ -0,0 +1,48 @@
from components.repositories.constants import ROUTE_ROOT, Routes
class Commands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def request_add_repository(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.AddRepository}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
def add_repository(self, tab_id: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
"hx-target": f"#{self._id}",
"hx-swap": "beforeend",
"hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def request_add_table(self, repository_name):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
}
def add_table(self, tab_id: str, repository_name: str):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
# The repository_name and the table_name will be given by the form
"hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "repository_name": "{repository_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}
def show_table(self, repo_name, table_name):
return {
"hx_get": f"{ROUTE_ROOT}{Routes.ShowTable}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "repository": "{repo_name}", "table": "{table_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
}

View File

@@ -4,14 +4,15 @@ from fasthtml.components import *
from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.addstuff.assets.icons import icon_database, icon_table
from components.addstuff.commands import Commands
from components.addstuff.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
from components.addstuff.settings import RepositoriesDbManager, Repository
from components.datagrid_new.components.DataGrid import DataGrid
from components.form.components.MyForm import MyForm, FormField
from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
from components.repositories.assets.icons import icon_database, icon_table
from components.repositories.commands import Commands
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
from components.repositories.db_management import RepositoriesDbManager, Repository
from components_helpers import mk_icon, mk_ellipsis
from core.instance_manager import InstanceManager
from core.utils import make_safe_id
logger = logging.getLogger("Repositories")
@@ -19,11 +20,11 @@ logger = logging.getLogger("Repositories")
class Repositories(BaseComponent):
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
super().__init__(session, _id)
self._settings_manager = settings_manager
self.db = RepositoriesDbManager(session, settings_manager)
self.commands = Commands(self)
self.tabs_manager = tabs_manager
self.db = RepositoriesDbManager(session, settings_manager)
self._settings_manager = settings_manager
self._contents = {} # ket tracks of already displayed contents
self._commands = Commands(self)
def request_new_repository(self):
# request for a new tab_id
@@ -45,26 +46,27 @@ class Repositories(BaseComponent):
# create and display the form in a new tab
self.tabs_manager.add_tab("Add New Table", add_table_form, tab_id=new_tab_id)
return self.tabs_manager
return self.tabs_manager.refresh()
def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
"""
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
:param form_id: form used to give the repository name (to be used in case of error)
:param repository_name: new repository name
:param table_name: default table name
:param tab_boundaries: tab boundaries
:return:
"""
try:
# Add the new repository and its default table to the list of repositories
tables = [MyTable(table_name, {})] if table_name else []
tables = [table_name] if table_name else []
repository = self.db.add_repository(repository_name, tables)
# update the tab content with table content
key = (repository_name, table_name)
self.tabs_manager.set_tab_content(tab_id,
self._get_table_content(key),
self._get_table_content(key, tab_boundaries),
title=table_name,
key=key,
active=True)
@@ -78,27 +80,28 @@ class Repositories(BaseComponent):
return self.tabs_manager.refresh()
def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
"""
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
:param form_id: form used to give the repository name (to be used in case of error)
:param repository_name: new repository name
:param table_name: default table name
:param tab_boundaries: tab boundaries
:return:
"""
try:
self.db.add_table(repository_name, table_name, {})
self.db.add_table(repository_name, table_name)
repository = self.db.get_repository(repository_name)
# update the tab content with table content
key = (repository_name, table_name)
self.tabs_manager.set_tab_content(tab_id,
self._get_table_content(key),
self._get_table_content(key, tab_boundaries),
title=table_name,
key=key,
active=True)
return self._mk_repository(repository, True), self.tabs_manager.refresh()
return self._mk_repository(repository, True, oob=True), self.tabs_manager.refresh()
except ValueError as ex:
logger.debug(f" Repository '{repository_name}' already exists.")
@@ -110,33 +113,33 @@ class Repositories(BaseComponent):
def select_repository(self, repository_name: str):
self.db.select_repository(repository_name)
def show_table(self, repository_name: str, table_name: str):
def show_table(self, repository_name: str, table_name: str, tab_boundaries: dict):
key = (repository_name, table_name)
self.tabs_manager.add_tab(table_name, self._get_table_content(key), key)
return self.tabs_manager
self.tabs_manager.add_tab(table_name, self._get_table_content(key, tab_boundaries), key)
return self.tabs_manager.refresh()
def refresh(self):
return self._mk_repositories(oob=True)
def __ft__(self):
return Div(
mk_tooltip_container(self._id),
Div(cls="divider"),
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),
self._mk_repositories(),
Script(f"bindRepositories('{self._id}')")
)
def _mk_repositories(self, oob=False):
settings = self.db._get_settings()
repositories = self.db.get_repositories()
selected_repository_name = self.db.get_selected_repository()
return Div(
*[self._mk_repository(repo, repo.name == settings.selected_repository_name)
for repo in settings.repositories],
*[self._mk_repository(repo, repo.name == selected_repository_name)
for repo in repositories],
Div("+ Add Repository", cls="text-sm", **self.commands.request_add_repository()),
id=self._id,
hx_swap_oob="true" if oob else None,
)
def _mk_repository(self, repo: Repository, selected):
def _mk_repository(self, repo: Repository, selected, oob=False) -> Div:
return Div(
Input(type="radio",
name=f"repo-accordion-{self._id}",
@@ -147,59 +150,59 @@ class Repositories(BaseComponent):
# hx_trigger="changed delay:500ms",
),
Div(
mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name),
mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name, cls="text-sm"),
cls="collapse-title p-0 min-h-0 flex truncate",
),
Div(
*[
Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table.name),
Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table_name, cls="text-sm"),
name="repo-table",
hx_get=f"{ROUTE_ROOT}{Routes.ShowTable}",
hx_target=f"#{self.tabs_manager.get_id()}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self._id}", "repository": "{repo.name}", "table": "{table.name}"}}',
cls="flex")
for table in repo.tables
cls="flex",
**self.commands.show_table(repo.name, table_name))
for table_name in repo.tables
],
Div("+ Add Table", **self._commands.request_add_table(repo.name)),
Div("+ Add Table", cls="text-sm", **self.commands.request_add_table(repo.name)),
cls="collapse-content pr-0! truncate",
),
tabindex="0", cls="collapse mb-2")
tabindex="0",
cls="collapse mb-2",
id=f"repo-{self._id}-{make_safe_id(repo.name)}",
hx_swap_oob="true" if oob else None,
)
def _mk_add_repository_form(self, tab_id: str):
htmx_params = {
"hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
"hx-target": f"#{self._id}",
"hx-swap": "beforeend",
}
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
title="Add Repository",
fields=[FormField("repository", 'Repository Name', 'input'),
FormField("table", 'First Table Name', 'input')],
htmx_params=htmx_params,
extra_values={"_id": self._id, "tab_id": tab_id})
htmx_request=self.commands.add_repository(tab_id),
)
def _mk_add_table_form(self, tab_id: str, repository_name: str = None):
htmx_request = self._commands.add_table()
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
title="Add Table",
fields=[FormField("repository_name", 'Repository Name', 'input',
value=repository_name,
disabled=True),
FormField("table_name", 'Table Name', 'input')],
htmx_request=htmx_request,
extra_values={"_id": self._id, "tab_id": tab_id, "repository_name": repository_name})
htmx_request=self.commands.add_table(tab_id, repository_name),
)
def _get_table_content(self, key):
def _get_table_content(self, key, tab_boundaries: dict):
if key in self._contents:
return self._contents[key]
content = self._contents[key]
if hasattr(content, "set_boundaries"):
content.set_boundaries(tab_boundaries)
return content
dg = InstanceManager.get(self._session,
DataGrid.create_component_id(self._session),
DataGrid,
settings_manager=self._settings_manager,
key=key)
key=key,
boundaries=tab_boundaries)
self._contents[key] = dg
return dg

View File

@@ -0,0 +1,8 @@
REPOSITORIES_INSTANCE_ID = "__Repositories__"
ROUTE_ROOT = "/repositories"
class Routes:
AddRepository = "/add-repo"
SelectRepository = "/select-repo"
AddTable = "/add-table"
ShowTable = "/show-table"

View File

@@ -0,0 +1,185 @@
import dataclasses
import logging
from core.settings_management import SettingsManager
REPOSITORIES_SETTINGS_ENTRY = "Repositories"
logger = logging.getLogger("AddStuffSettings")
@dataclasses.dataclass
class Repository:
name: str
tables: list[str]
@dataclasses.dataclass
class RepositoriesSettings:
repositories: list[Repository] = dataclasses.field(default_factory=list)
selected_repository_name: str = None
@staticmethod
def use_refs():
return {"repositories"}
class RepositoriesDbManager:
def __init__(self, session: dict, settings_manager: SettingsManager):
self.session = session
self.settings_manager = settings_manager
def add_repository(self, repository_name: str, tables: list[str] = None):
"""
Adds a new repository to the list of repositories. The repository is identified
by its name and can optionally include a list of associated tables.
:param repository_name: The name of the repository to be added.
:param tables: A list of Table objects to be associated with the repository,
defaulting to an empty list if not provided.
:type tables: list[Table], optional
:return: None
"""
settings = self._get_settings()
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
if repository_name in [repo.name for repo in settings.repositories]:
raise ValueError(f"Repository '{repository_name}' already exists.")
existing_repositories = [r.name for r in settings.repositories]
logger.info(f"Existing repositories:{existing_repositories}")
repository = Repository(repository_name, tables or [])
settings.repositories.append(repository)
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return repository
def get_repository(self, repository_name: str):
if repository_name is None or repository_name == "":
raise ValueError("Repository name cannot be empty.")
settings = self._get_settings()
if repository_name not in [repo.name for repo in settings.repositories]:
raise ValueError(f"Repository '{repository_name}' does not exist.")
return next(filter(lambda r: r.name == repository_name, settings.repositories))
def modify_repository(self, old_repository_name, new_repository_name: str, tables: list[str]):
if not old_repository_name or not new_repository_name:
raise ValueError("Repository name cannot be empty.")
settings = self._get_settings()
for repository in settings.repositories:
if repository.name == old_repository_name:
repository.name = new_repository_name
repository.tables = tables
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return repository
else:
raise ValueError(f"Repository '{old_repository_name}' not found.")
def remove_repository(self, repository_name):
if not repository_name:
raise ValueError("Repository name cannot be empty.")
settings = self._get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
raise ValueError(f"Repository '{repository_name}' does not exist.")
settings.repositories.remove(repository)
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return repository
def get_repositories(self):
return self._get_settings().repositories
def add_table(self, repository_name: str, table_name: str):
"""
Adds a table to the specified repository
:param repository_name: The name of the target repository.
:param table_name: The name of the table to add.
:return: None
"""
settings, repository = self._get_settings_and_repo(repository_name, table_name, t1_must_exists=False)
repository.tables.append(table_name)
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
def modify_table(self, repository_name: str, old_table_name: str, new_table_name: str):
"""
Modifies the name of a table in the specified repository.
:param repository_name: The name of the repository containing the table.
:param old_table_name: The current name of the table to be modified.
:param new_table_name: The new name for the table.
:return: None
"""
settings, repository = self._get_settings_and_repo(repository_name, old_table_name, new_table_name)
table_index = repository.tables.index(old_table_name)
repository.tables[table_index] = new_table_name
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
def remove_table(self, repository_name: str, table_name: str):
"""
Removes a table from the specified repository.
:param repository_name: The name of the repository containing the table.
:param table_name: The name of the table to be removed.
:return: None
"""
settings, repository = self._get_settings_and_repo(repository_name, table_name)
repository.tables.remove(table_name)
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
def select_repository(self, repository_name: str):
"""
Select and save the specified repository name in the current session's settings.
:param repository_name: The name of the repository to be selected and stored.
:type repository_name: str
:return: None
"""
settings = self._get_settings()
settings.selected_repository_name = repository_name
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
def get_selected_repository(self):
settings = self._get_settings()
return settings.selected_repository_name
def _get_settings(self):
return self.settings_manager.load(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
def _get_settings_and_repo(self, repository_name, *tables_names, t1_must_exists=True, t2_must_exists=False):
if not repository_name:
raise ValueError("Repository name cannot be empty.")
for table_name in tables_names:
if not table_name:
raise ValueError("Table name cannot be empty.")
settings = self._get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
raise ValueError(f"Repository '{repository_name}' does not exist.")
for table_name, must_exist in zip(tables_names, [t1_must_exists, t2_must_exists]):
if must_exist:
if table_name not in repository.tables:
raise ValueError(f"Table '{table_name}' does not exist in repository '{repository_name}'.")
else:
if table_name in repository.tables:
raise ValueError(f"Table '{table_name}' already exists in repository '{repository_name}'.")
return settings, repository

View File

@@ -1,20 +1,27 @@
.tabs {
background-color: var(--color-base-200);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
border-radius: .5rem;
width: 100%;
height: 100%;
.mmt-tabs {
display: flex;
flex-direction: column;
background-color: var(--color-base-200);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
border-radius: .5rem;
width: 100%;
height: 100%;
}
.tabs-content {
.mmt-tabs-header {
display: flex;
min-height: 25px;
}
.mmt-tabs-content {
display: block;
width: 100%;
height: 100%;
flex: 1;
background-color: var(--color-base-100);
padding: 4px;
}
.tabs-tab {
.mmt-tabs-tab {
cursor: pointer;
appearance: none;
text-align: center;
@@ -28,15 +35,15 @@
}
.tabs-tab:hover {
.mmt-tabs-tab:hover {
color: var(--color-base-content); /* Change text color on hover */
}
.tabs-label {
.mmt-tabs-label {
max-width: 150px;
}
.tabs-active {
.mmt-tabs-active {
--depth: 1;
background-color: var(--color-base-100);
color: var(--color-base-content);

View File

@@ -1,3 +1,11 @@
function bindTabs(tabsId) {
bindTooltipsWithDelegation(tabsId)
function getTabContentBoundaries(tabsId) {
const tabsContainer = document.getElementById(tabsId)
console.debug("tabsContainer", tabsContainer)
const contentDiv = tabsContainer.querySelector('.mmt-tabs-content')
const boundaries = contentDiv.getBoundingClientRect()
return {
width: boundaries.width,
height: boundaries.height
}
}

View File

@@ -2,12 +2,11 @@ import dataclasses
import logging
from fasthtml.components import *
from fasthtml.xtend import Script
from assets.icons import icon_dismiss_regular
from components.BaseComponent import BaseComponent
from components.tabs.constants import MY_TABS_INSTANCE_ID, Routes, ROUTE_ROOT
from components_helpers import mk_ellipsis, mk_tooltip_container
from components_helpers import mk_ellipsis
from core.instance_manager import InstanceManager
from core.utils import get_unique_id
@@ -151,12 +150,9 @@ class MyTabs(BaseComponent):
return self.render(oob=True)
def __ft__(self):
return mk_tooltip_container(self._id), self.render(), Script(f"bindTabs('{self._id}')")
return self.render()
def render(self, oob=False):
if not self.tabs:
return Div(id=self._id, hx_swap_oob="true" if oob else None)
active_content = self.get_active_tab_content()
if hasattr(active_content, "on_htmx_after_settle"):
extra_params = {"hx-on::after-settle": active_content.on_htmx_after_settle()}
@@ -164,9 +160,9 @@ class MyTabs(BaseComponent):
extra_params = {}
return Div(
*[self._mk_tab(tab) for tab in self.tabs], # headers
Div(active_content, cls="tabs-content"),
cls="tabs",
Div(*[self._mk_tab(tab) for tab in self.tabs], cls="mmt-tabs-header"), # headers
Div(active_content, cls="mmt-tabs-content"),
cls="mmt-tabs",
id=self._id,
hx_swap_oob="true" if oob else None,
**extra_params,
@@ -174,9 +170,9 @@ class MyTabs(BaseComponent):
def _mk_tab(self, tab: Tab):
return Span(
Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="tabs-label truncate"),
Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="mmt-tabs-label truncate"),
Div(icon_dismiss_regular, cls="icon-16 ml-2", hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
cls=f"tabs-tab {'tabs-active' if tab.active else ''}",
cls=f"mmt-tabs-tab {'mmt-tabs-active' if tab.active else ''}",
hx_vals=f'{{"_id": "{self._id}", "tab_id":"{tab.id}"}}',
hx_target=f"#{self._id}",
hx_swap="outerHTML",

View File

@@ -3,7 +3,7 @@ import logging
from fasthtml.fastapp import fast_app
from components.themecontroller.constants import Routes
from core.instance_manager import InstanceManager
from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("ThemeControllerApp")
@@ -12,6 +12,6 @@ theme_controller_app, rt = fast_app()
@rt(Routes.ChangeTheme)
def post(session, _id: str, theme: str):
logger.debug(f"Entering {Routes.ChangeTheme} with args {session=}, {theme=}")
logger.debug(f"Entering {Routes.ChangeTheme} with args {debug_session(session)}, {theme=}")
instance = InstanceManager.get(session, _id)
instance.change_theme(theme)

View File

@@ -6,7 +6,7 @@ from fasthtml.svg import *
from components.themecontroller.constants import ROUTE_ROOT, Routes
from components.themecontroller.settings import THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings
from core.settings_management import SettingsManager
from core.settings_management import SettingsManager, GenericDbManager
logger = logging.getLogger("ThemeController")
@@ -14,12 +14,7 @@ logger = logging.getLogger("ThemeController")
class ThemeController:
def __init__(self, session, settings_manager: SettingsManager, /, _id=None):
self._id = _id or uuid.uuid4().hex
self.session = session
self.settings_manager = settings_manager
self.settings = self.settings_manager.get(session,
THEME_CONTROLLER_SETTINGS_ENTRY,
default=ThemeControllerSettings())
self.db = GenericDbManager(session, settings_manager, THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings)
def __ft__(self):
return Div(
@@ -46,7 +41,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Default', value='default',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
checked=self.settings.theme is None or self.settings.theme == 'default',
checked=self.db.theme is None or self.db.theme == 'default',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -54,7 +49,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Dark', value='dark',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
checked=self.settings.theme == 'dark',
checked=self.db.theme == 'dark',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -62,7 +57,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Light', value='light',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
checked=self.settings.theme == 'light',
checked=self.db.theme == 'light',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -70,15 +65,7 @@ class ThemeController:
Li(
Input(type='radio', name='theme', aria_label='Cupcake', value='cupcake',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
checked=self.settings.theme == 'cupcake',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
),
Li(
Input(type='radio', name='theme', aria_label='Lofi', value='lofi',
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
checked=self.settings.theme == 'lofi',
checked=self.db.theme == 'cupcake',
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
hx_vals=f'{{"_id": "{self._id}"}}',
)
@@ -91,5 +78,4 @@ class ThemeController:
def change_theme(self, theme):
logger.debug(f"change_theme - Changing theme to '{theme}'.")
self.settings.theme = theme
self.settings_manager.put(self.session, THEME_CONTROLLER_SETTINGS_ENTRY, self.settings)
self.db.theme = theme

View File

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

View File

@@ -19,7 +19,7 @@ def mk_ellipsis(txt: str, cls='', **kwargs):
def mk_tooltip_container(component_id):
return Div(id=f"tt_{component_id}", style="position: fixed; z-index: 1000;", cls="mmt-tooltip-container"),
return Div(id=f"tt_{component_id}", style="position: fixed; z-index: 1000;", cls="mmt-tooltip-container")
def mk_dialog_buttons(ok_title: str = "OK", cancel_title: str = "Cancel", on_ok: dict = None, on_cancel: dict = None):

View File

@@ -28,8 +28,8 @@ SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
# GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "/auth/github/callback")
# Admin user (created on first run if provided)
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
logger.info(f"{ADMIN_EMAIL=}")
# Session expiration (in seconds)

View File

@@ -69,12 +69,10 @@ class DbEngine:
Designed to keep history of the modifications
"""
ObjectsFolder = "objects" # group objects in the same folder
HeadFile = "head" # used to keep track the latest version of all entries
HeadFile = "head" # used to keep track of the latest version of all entries
def __init__(self, root: str = None):
self.root = root or ".mytools_db"
self.serializer = Serializer(RefHelper(self._get_ref_path))
self.debug_serializer = DebugSerializer(RefHelper(self._get_ref_path))
self.lock = RLock()
def is_initialized(self, user_id: str):
@@ -196,7 +194,7 @@ class DbEngine:
self.save(user_id, user_email, entry, entry_content)
return True
def put_many(self, user_id: str, user_email, entry, items: list):
def put_many(self, user_id: str, user_email, entry, items: list | dict):
"""
Save a list of item as one single snapshot
A new snapshot will not be created if all the items already exist
@@ -216,13 +214,24 @@ class DbEngine:
entry_content = {}
is_dirty = False
for item in items:
key = item.get_key()
if key in entry_content and entry_content[key] == item:
continue
else:
entry_content[key] = item
is_dirty = True
if isinstance(items, dict):
for key, item in items.items():
if key in entry_content and entry_content[key] == item:
continue
else:
entry_content[key] = item
is_dirty = True
else:
for item in items:
key = item.get_key()
if key in entry_content and entry_content[key] == item:
continue
else:
entry_content[key] = item
is_dirty = True
if is_dirty:
self.save(user_id, user_email, entry, entry_content)
@@ -257,7 +266,10 @@ class DbEngine:
# return all items as list
return [v for k, v in entry_content.items() if not k.startswith("__")]
return entry_content[key]
try:
return entry_content[key]
except KeyError:
raise DbException(f"Key '{key}' not found in entry '{entry}'")
def debug_root(self):
"""
@@ -287,7 +299,18 @@ class DbEngine:
with open(target_file, 'r', encoding='utf-8') as file:
as_dict = json.load(file)
return self.debug_serializer.deserialize(as_dict)
debug_serializer = DebugSerializer(RefHelper(self._get_ref_path))
return debug_serializer.deserialize(as_dict)
def debug_users(self):
"""
Returns a list of all user folders inside the root directory, excluding the 'refs' folder
:return: List of folder names
"""
with self.lock:
if not os.path.exists(self.root):
return []
return [f for f in os.listdir(self.root) if os.path.isdir(os.path.join(self.root, f)) and f != 'refs']
def debug_get_digest(self, user_id, entry):
return self._get_entry_digest(user_id, entry)
@@ -298,12 +321,15 @@ class DbEngine:
:param obj:
:return:
"""
# serializer = Serializer(RefHelper(self._get_obj_path))
use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
return self.serializer.serialize(obj, use_refs)
with self.lock:
serializer = Serializer(RefHelper(self._get_ref_path))
use_refs = getattr(obj, "use_refs")() if hasattr(obj, "use_refs") else None
return serializer.serialize(obj, use_refs)
def _deserialize(self, as_dict):
return self.serializer.deserialize(as_dict)
with self.lock:
serializer = Serializer(RefHelper(self._get_ref_path))
return serializer.deserialize(as_dict)
def _update_head(self, user_id, entry, digest):
"""
@@ -368,4 +394,4 @@ class DbEngine:
:param digest:
:return:
"""
return os.path.join(self.root, "refs", digest[:24], digest)
return os.path.join(self.root, "refs", digest[:24], digest)

View File

@@ -1,8 +1,7 @@
import json
import logging
import os.path
from datetime import datetime
from core.dbengine import DbEngine, DbException
from core.dbengine import DbEngine, TAG_PARENT, TAG_USER, TAG_DATE, DbException
from core.instance_manager import NO_SESSION, NOT_LOGGED
from core.settings_objects import *
@@ -20,56 +19,6 @@ class NoDefaultCls:
NoDefault = NoDefaultCls()
class DummyDbEngine:
"""
Dummy DB engine
Can only serialize object defined in settings_object module
Save everything in a single file
"""
def __init__(self, setting_path="settings.json"):
self.db_path = setting_path
def save(self, user_id: str, entry: str, obj: object) -> bool:
if not hasattr(obj, "as_dict"):
raise Exception("'as_dict' not found. Not supported")
as_dict = getattr(obj, "as_dict")()
as_dict["__type__"] = type(obj).__name__
if os.path.exists(self.db_path):
with open(self.db_path, "r") as settings_file:
as_json = json.load(settings_file)
as_json[entry] = as_dict
with open(self.db_path, "w") as settings_file:
json.dump(as_json, settings_file)
else:
as_json = {entry: as_dict}
with open(self.db_path, "w") as settings_file:
json.dump(as_json, settings_file)
return True
def load(self, user_id: str, entry: str, digest: str = None):
try:
with open(self.db_path, "r") as settings_file:
as_json = json.load(settings_file)
as_dict = as_json[entry]
obj_type = as_dict.pop("__type__")
obj = globals()[obj_type]()
getattr(obj, "from_dict")(as_dict)
return obj
except Exception as ex:
raise DbException(f"Entry '{entry}' is not found.")
def is_initialized(self):
return os.path.exists(self.db_path)
def init(self):
pass
class MemoryDbEngine:
"""
Keeps everything in memory
@@ -81,127 +30,108 @@ class MemoryDbEngine:
def init_db(self, entry, key, obj):
self.db[entry] = {key: obj}
def save(self, user_id: str, entry: str, obj: object) -> bool:
self.db[entry] = obj
def save(self, user_id: str, user_email: str, entry: str, obj: object) -> bool:
if user_id in self.db:
self.db[user_id][entry] = obj
self.db[user_id][TAG_PARENT] = [] # not used
self.db[user_id][TAG_USER] = user_email
self.db[user_id][TAG_DATE] = datetime.now().strftime('%Y%m%d %H:%M:%S %z')
else:
self.db[user_id] = {
entry: obj,
TAG_PARENT: [], # not set
TAG_USER: user_email,
TAG_DATE: datetime.now().strftime('%Y%m%d %H:%M:%S %z')
}
return True
def load(self, user_id: str, entry: str, digest: str = None):
try:
return self.db[entry]
except KeyError:
return {}
return self.db[user_id][entry]
except KeyError as e:
raise DbException(e)
def get(self, user_id: str, entry: str, key: str | None = None, digest=None):
return self.db[entry][key]
"""
Retrieve a specific value or the entire object from stored data based on the
provided user ID and entry name. Optionally, a key can be specified to extract
a particular value from the loaded object.
:param user_id: The unique identifier of the user associated with the data
being retrieved.
:param entry: The name of the entry under the user's data storage.
:param key: Optional; Specific key for retrieving a particular value from the
entry. If not provided, the entire object is returned.
:param digest: Optional; Used to specify a digest or additional parameter,
but its function should be inferred from its use, as it is not directly
handled in this method.
:return: The value corresponding to the specified key within the user's entry,
or the entire entry object if no key is specified.
"""
obj = self.load(user_id, entry)
try:
return obj[key] if key else obj
except KeyError as e:
raise DbException(e)
def put(self, user_id: str, entry, key: str, value: object):
if entry not in self.db:
self.db[entry] = {}
self.db[entry][key] = value
def put(self, user_id: str, user_email: str, entry, key: str, value: object):
obj = self.load(user_id, entry)
obj[key] = value
def is_initialized(self):
return True
def put_many(self, user_id: str, user_email: str, entry, items):
obj = self.load(user_id, entry)
obj.update(items)
def exists(self, user_id: str, entry: str):
return user_id in entry and entry in self.db[user_id]
class SettingsManager:
def __init__(self, engine=None):
self._db_engine = engine or DbEngine()
def save(self, user_id: str, entry: str, obj: object):
return self._db_engine.save(user_id, entry, obj)
def save(self, session: dict, entry: str, obj: object):
user_id, user_email = self._get_user(session)
return self._db_engine.save(user_id, user_email, entry, obj)
def load(self, user_id: str, entry: str):
return self._db_engine.load(user_id, entry)
def get_all(self, user_id: str, entry: str):
""""
Returns all the items of an entry
"""
return self._db_engine.get(user_id, entry, None)
def put(self, session: dict, key: str, value: object):
"""
Inserts or updates a key-value pair in the database for the current user session.
The method extracts the user ID and email from the session dictionary and
utilizes the database engine to perform the storage operation.
:param session: A dictionary containing session-specific details,
including 'user_id' and 'user_email'.
:type session: dict
:param key: The key under which the value should be stored in the database.
:type key: str
:param value: The value to be stored, associated with the specified key.
:type value: object
:return: The result of the database engine's put operation.
:rtype: object
"""
user_id = session["user_id"] if session else NO_SESSION
user_email = session["user_email"] if session else NOT_LOGGED
return self._db_engine.put(user_email, str(user_id), key, value)
def get(self, session: dict, key: str | None = None, default=NoDefault):
"""
Fetches a value associated with a specific key for a user session from the
database. If the key is not found in the database and a default value is
provided, returns the default value. If no default is provided and the key
is not found, raises a KeyError.
:param session: A dictionary containing session data. Must include "user_id"
and "user_email" keys.
:type session: dict
:param key: The key to fetch from the database for the given session user.
Defaults to None if not specified.
:type key: str | None
:param default: The default value to return if the key is not found in the
database. If not provided, raises KeyError when the key is missing.
:type default: Any
:return: The value associated with the key for the user session if found in
the database, or the provided default value if the key is not found.
"""
def load(self, session: dict, entry: str, default=NoDefault):
user_id, _ = self._get_user(session)
try:
user_id = session["user_id"] if session else NO_SESSION
user_email = session["user_email"] if session else NOT_LOGGED
return self._db_engine.get(user_email, str(user_id), key)
except KeyError:
return self._db_engine.load(user_id, entry)
except DbException:
return default
def put(self, session: dict, entry: str, key: str, value: object):
user_id, user_email = self._get_user(session)
return self._db_engine.put(user_id, user_email, entry, key, value)
def put_many(self, session: dict, entry: str, items: list | dict):
user_id, user_email = self._get_user(session)
return self._db_engine.put_many(user_id, user_email, entry, items)
def get(self, session: dict, entry: str, key: str | None = None, default=NoDefault):
try:
user_id, _ = self._get_user(session)
return self._db_engine.get(user_id, entry, key)
except DbException:
if default is NoDefault:
raise
else:
return default
def remove(self, session: dict, key: str):
user_id = session["user_id"] if session else NO_SESSION
user_email = session["user_email"] if session else NOT_LOGGED
return self._db_engine.remove(user_email, user_id, key)
def exists(self, session: dict, entry: str):
user_id, _ = self._get_user(session)
def update(self, session: dict, old_key: str, key: str, value: object):
user_id = session["user_id"] if session else NO_SESSION
user_email = session["user_email"] if session else NOT_LOGGED
def _update_helper(_old_key, _key, _value):
pass
if hasattr(self._db_engine, "lock"):
with self._db_engine.lock:
_update_helper(old_key, key, value)
else:
_update_helper(old_key, key, value)
def init_user(self, user_id: str, user_email: str):
"""
Init the settings block space for a user
:param user_id:
:param user_email:
:return:
"""
if not self._db_engine.exists(user_id):
self._db_engine.save(user_email, user_id, {})
def get_db_engine_root(self):
return os.path.abspath(self._db_engine.root)
return self._db_engine.exists(user_id, entry)
def get_db_engine(self):
return self._db_engine
@staticmethod
def _get_user(session):
user_id = str(session.get("user_id", NOT_LOGGED)) if session else NO_SESSION
user_email = session.get("user_email", NOT_LOGGED) if session else NO_SESSION
return user_id, user_email
class SettingsTransaction:
@@ -223,6 +153,31 @@ class SettingsTransaction:
if exc_type is None:
self._settings_manager.save(self._user_email, self._user_id, self._entries)
#
# settings_manager = SettingsManager()
# settings_manager.init()
class GenericDbManager:
def __init__(self, session, settings_manager: SettingsManager, obj_entry, obj_type):
self.__dict__["_session"] = session
self.__dict__["_settings_manager"] = settings_manager
self.__dict__["_obj_entry"] = obj_entry
self.__dict__["_obj_type"] = obj_type
def __setattr__(self, key, value):
if key.startswith("_"):
super().__setattr__(key, value)
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
if not (hasattr(settings, key)):
raise AttributeError(f"Settings {self._obj_entry.__name__} has no attribute {key}")
setattr(settings, key, value)
self._settings_manager.save(self._session, self._obj_entry, settings)
def __getattr__(self, item):
if item.startswith("_"):
return super().__getattribute__(item)
settings = self._settings_manager.load(self._session, self._obj_entry, self._obj_type())
if not (hasattr(settings, item)):
raise AttributeError(f"Settings {self._obj_entry.__name__} has no attribute {item}")
return getattr(settings, item)

View File

@@ -199,7 +199,7 @@ def snake_case_to_capitalized_words(s: str) -> str:
return transformed_name
def make_column_id(s: str | None):
def make_safe_id(s: str | None):
if s is None:
return None

View File

@@ -1,42 +1,26 @@
# global layout
import logging.config
import requests
import yaml
from fasthtml.common import *
from assets.css import my_managing_tools_style
from auth.auth_manager import AuthManager
from components.DrawerLayoutOld import DrawerLayout as DrawerLayoutOld
from components.DrawerLayoutOld import Page
from components.addstuff.AddStuffApp import add_stuff_app
from components.addstuff.constants import ROUTE_ROOT as ADD_STUFF_ROUTE_ROOT
from components.datagrid.DataGrid import DATAGRID_PATH, datagrid_app
from components.datagrid_new.DataGridApp import datagrid_new_app
from components.datagrid_new.constants import ROUTE_ROOT as DATAGRID_NEW_ROUTE_ROOT
from components.debugger.DebuggerApp import debugger_app
from components.debugger.constants import ROUTE_ROOT as DEBUGGER_ROUTE_ROOT
from components.drawerlayout.DrawerLayoutApp import drawer_layout_app
from components.drawerlayout.components import DrawerLayout
from components.drawerlayout.components.DrawerLayout import DrawerLayout
from components.drawerlayout.constants import ROUTE_ROOT as DRAWER_LAYOUT_ROUTE_ROOT
from components.form.FormApp import form_app
from components.form.constants import ROUTE_ROOT as FORM_ROUTE_ROOT
from components.login.LoginApp import login_app
from components.login.components.Login import Login
from components.login.constants import ROUTE_ROOT as LOGIN_ROUTE_ROOT
from components.login.constants import Routes as LoginRoutes
from components.page_layout_new import page_layout_new, page_layout_lite
from components.register.RegisterApp import register_app
from components.register.components.Register import Register
from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
from components.register.constants import Routes as RegisterRoutes
from components.tabs.TabsApp import tabs_app
from components.tabs.constants import ROUTE_ROOT as TABS_ROUTE_ROOT
from components.themecontroller.ThemeControllerApp import theme_controller_app
from components.themecontroller.constants import ROUTE_ROOT as THEME_CONTROLLER_ROUTE_ROOT
from constants import Routes
from core.dbengine import DbException
from core.instance_manager import NO_SESSION, NOT_LOGGED, InstanceManager
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
from pages.another_grid import get_datagrid2
@@ -52,59 +36,148 @@ with open('logging.yaml', 'r') as f:
# At the top of your script or module
logging.config.dictConfig(config)
logger = logging.getLogger("MainApp")
# daisy_ui_links_v4 = (
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
# Script(src="https://cdn.tailwindcss.com"),
# )
daisy_ui_links_v4 = (
Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
Script(src="./assets/tailwindcss.js"),
)
daisy_ui_links = (
links = [
# start with daisyui
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5", rel="stylesheet", type="text/css"),
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css", rel="stylesheet", type="text/css"),
Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),
)
main_links = (Script(src="./assets/main.js"),
Link(rel="stylesheet", href="./assets/main.css", type="text/css"),)
drawer_layout = (Script(src="./components/drawerlayout/assets/DrawerLayout.js"),
Link(rel="stylesheet", href="./components/drawerlayout/assets/DrawerLayout.css"),)
datagridOld = (Script(src="./components/datagrid/DataGrid.js"),
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"))
drw_layout_old = (Script(src="./assets/DrawerLayout.js", defer=True),
Link(rel="stylesheet", href="./assets/DrawerLayout.css"))
datagrid = (Script(src="./components/datagrid_new/assets/Datagrid.js"),
Link(rel="stylesheet", href="./components/datagrid_new/assets/Datagrid.css"))
addstuff = (Script(src="./components/addstuff/assets/addstuff.js"),)
tabs = (Script(src="./components/tabs/assets/tabs.js"),
Link(rel="stylesheet", href="./components/tabs/assets/tabs.css"),)
debugger = (Script(type="module", src="./components/debugger/assets/Debugger.js"),)
routes = (
Mount(LOGIN_ROUTE_ROOT, login_app, name="login"),
Mount(REGISTER_ROUTE_ROOT, register_app, name="register"),
Mount(THEME_CONTROLLER_ROUTE_ROOT, theme_controller_app, name="theme_controller"),
Mount(DRAWER_LAYOUT_ROUTE_ROOT, drawer_layout_app, name="main"),
Mount(ADD_STUFF_ROUTE_ROOT, add_stuff_app, name="add_stuff"),
Mount(TABS_ROUTE_ROOT, tabs_app, name="tabs"),
Mount(FORM_ROUTE_ROOT, form_app, name="form"),
Mount(DATAGRID_NEW_ROUTE_ROOT, datagrid_new_app, name="datagrid_new"),
Mount(DEBUGGER_ROUTE_ROOT, debugger_app, name="debugger"),
# Old drawer layout
Script(src="./assets/DrawerLayout.js", defer=True),
Link(rel="stylesheet", href="./assets/DrawerLayout.css"),
# Old datagrid
Script(src="./components/datagrid/DataGrid.js"),
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
# add main
Script(src="./assets/main.js"),
Link(rel="stylesheet", href="./assets/main.css", type="text/css")
]
routes = []
def register_component(name, path, app_module_name):
def _get_fast_app(_module):
if app_module_name is None:
logger.debug(f" No app module defined for {_module.__name__}. Skipping fast app lookup.")
return None
for var_name in dir(_module):
if var_name.endswith('App'):
component_module = getattr(_module, var_name)
logger.debug(f" Found component module {component_module.__name__}")
for sub_var_name in dir(component_module):
if sub_var_name.endswith("_app"):
res = getattr(component_module, sub_var_name)
if isinstance(res, FastHTML):
logger.debug(f" Found FastHTML app component {sub_var_name}")
return res
raise ValueError(f" Cannot find app for component {name}")
def _get_route_root(_module):
if app_module_name is None:
logger.debug(f" No app module defined for {_module.__name__}. Skipping route root lookup.")
return None
constants_module = getattr(_module, "constants")
return getattr(constants_module, "ROUTE_ROOT")
def _get_links(_module):
# Get module file path and construct assets directory path
module_path = os.path.dirname(_module.__file__)
assets_path = os.path.join(module_path, 'assets')
if not os.path.exists(assets_path):
logger.debug(f" No 'assets' folder not found in module {_module.__name__}")
return None
# Find all CSS files in assets directory
files = []
for file in os.listdir(assets_path):
if file.endswith('.css'):
file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
files.append(Link(rel="stylesheet", href=file_path, type="text/css"))
logger.debug(f" Found CSS file {file_path}")
elif file.endswith('.js'):
file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
files.append(Script(src=file_path))
logger.debug(f" Found JS file {file_path}")
return files if files else None
try:
# Import the module dynamically
logger.debug(f"register_component {name=} {path=} {app_module_name=}")
module = __import__(path, fromlist=['*', app_module_name] if app_module_name else ['*'])
component_app = _get_fast_app(module)
component_route_root = _get_route_root(module)
component_links = _get_links(module)
if component_app is not None:
routes.append(Mount(component_route_root, component_app, name=name))
if component_links is not None:
links.extend(component_links)
except ImportError:
logging.error(f"Could not import module {path}")
def query_mistral(prompt):
"""Send a query to the Mistral model via Ollama API"""
ollama_host = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
response = requests.post(
f"{ollama_host}/api/generate",
json={
"model": "mistral",
"prompt": prompt,
"stream": False
}
)
return response.json()
register_component("login", "components.login", "LoginApp")
register_component("register", "components.register", "RegisterApp")
register_component("theme_controller", "components.themecontroller", "ThemeControllerApp")
register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
register_component("tabs", "components.tabs", "TabsApp") # before repositories
register_component("repositories", "components.repositories", "RepositoriesApp")
register_component("add_stuff", "components.addstuff", None)
register_component("form", "components.form", "FormApp")
register_component("datagrid_new", "components.datagrid_new", "DataGridApp")
register_component("debugger", "components.debugger", "DebuggerApp")
routes.extend([
# old stuffs
Mount(f"/{BASIC_TEST_PATH}", basic_test_app, name="basic test"),
Mount(f"/{DATAGRID_PATH}", datagrid_app, name="datagrid"),
Mount(f"/{IMPORT_SETTINGS_PATH}", import_settings_app, name="import_settings"),
])
old_links = (
# daisy_ui_links_v4
Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
Script(src="./assets/tailwindcss.js"),
# datagridOld
Script(src="./components/datagrid/DataGrid.js"),
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
# drw_layout_old
Script(src="./assets/DrawerLayout.js", defer=True),
Link(rel="stylesheet", href="./assets/DrawerLayout.css")
)
@@ -133,18 +206,14 @@ bware = Beforeware(before, skip=[r'/favicon\.ico',
app, rt = fast_app(
before=bware,
hdrs=(daisy_ui_links,
main_links, my_managing_tools_style,
drawer_layout, addstuff,
tabs, debugger, datagrid),
hdrs=tuple(links),
live=True,
routes=routes,
routes=tuple(routes),
debug=True,
pico=False,
)
settings_manager = SettingsManager()
settings_manager.init_user(NO_SESSION, NOT_LOGGED)
import_settings = AdminImportSettings(settings_manager, None)
pages = [
@@ -181,7 +250,7 @@ def get(session):
@rt("/test")
def get(session):
return (Title("Another Project Management"),
datagridOld, drw_layout_old, daisy_ui_links_v4,
old_links,
Input(type='checkbox', value='light', cls='toggle theme-controller'),
DrawerLayoutOld(pages),)
@@ -219,4 +288,19 @@ def get(session):
return Titled("I like toast")
serve(port=5001)
if __name__ == "__main__":
# Start your application
print("Application starting...")
print("Checking if Mistral model is available...")
try:
requests.post(
f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/pull",
json={"name": "mistral"}
)
print("Mistral model is ready")
except Exception as e:
print(f"Error pulling Mistral model: {e}")
serve(port=5001)

View File

@@ -11,7 +11,7 @@ from components.datagrid.DataGrid import DataGrid, DG_FILTER_INPUT, DG_TABLE_FOO
DG_DATATYPE_BOOL, DG_READ_ONLY
from components.datagrid.constants import DG_ROWS_INDEXES
from core.settings_management import SettingsManager
from core.utils import make_html_id, make_column_id
from core.utils import make_html_id, make_safe_id
ID_PREFIX = "import_settings"
@@ -152,7 +152,7 @@ class AdminImportSettings:
def get_columns_def(self, columns):
res = []
_mapping = {
"Column Id": lambda c: make_column_id(str(c).strip()),
"Column Id": lambda c: make_safe_id(str(c).strip()),
"Column Header": lambda c: c,
"Amount": lambda c: False,
}

View File

@@ -8,7 +8,7 @@ import pandas as pd
from bs4 import BeautifulSoup
from fastcore.basics import NotStr
from fastcore.xml import to_xml
from fasthtml.components import html2ft, Div
from fasthtml.components import html2ft, Div, Span
pattern = r"""(?P<tag>\w+)(?:#(?P<id>[\w-]+))?(?P<attributes>(?:\[\w+=['"]?[\w_-]+['"]?\])*)"""
attr_pattern = r"""\[(?P<name>\w+)=['"]?(?P<value>[\w_-]+)['"]?\]"""
@@ -17,6 +17,7 @@ compiled_pattern = re.compile(pattern)
compiled_attr_pattern = re.compile(attr_pattern)
compiled_svg_pattern = re.compile(svg_pattern)
@dataclasses.dataclass
class DoNotCheck:
desc: str = None
@@ -33,6 +34,7 @@ class StartsWith:
"""
s: str
@dataclasses.dataclass
class Contains:
"""
@@ -40,6 +42,7 @@ class Contains:
"""
s: str
Empty = EmptyElement()
@@ -141,7 +144,10 @@ def search_elements_by_name(ft, tag: str = None, attrs: dict = None, comparison_
# Recursive case: search through the children
for child in _ft.children:
result.extend(_search_elements_by_name(child))
elif isinstance(_ft, (list, tuple)):
for _item in _ft:
result.extend(_search_elements_by_name(_item))
return result
if isinstance(ft, list):
@@ -156,7 +162,7 @@ def search_elements_by_name(ft, tag: str = None, attrs: dict = None, comparison_
def search_elements_by_path(ft, path: str, attrs: dict = None):
"""
Selects elements that match a given path. The path is a dot-separated list of elements.
One the path if found, the optional attributes are compared against the last element's
Once the path if found, the optional attributes are compared against the last element's
attributes.
Note the path may not start at the root node of the tree structure.
@@ -188,10 +194,10 @@ def search_elements_by_path(ft, path: str, attrs: dict = None):
return _find(ft, "")
def search_first_with_attribute(ft, tag, attribute
):
def search_first_with_attribute(ft, tag, attribute):
"""
Browse ft and its children to find the first element that matches the tag and has the attribute defined
We do not care about the value of the attribute, just the presence of it.
if tag is None, it will return the first element with the attribute
:param ft:
:param tag:
@@ -266,6 +272,83 @@ def matches(actual, expected, path=""):
return type(x)
def _debug(_actual, _expected):
str_actual = _debug_print_actual(_actual, _expected, "", 3)
str_expected = _debug_print_expected(_expected, "", 2)
return f"\nactual={str_actual}\nexpected={str_expected}"
def _debug_value(x):
if x in ("** NOT FOUND **", "** NONE **", "** NO MORE CHILDREN **"):
return x
elif isinstance(x, str):
return f"'{x}'" if "'" not in x else f'"{x}"'
else:
return x
def _debug_print_actual(_actual, _expected, indent, max_level):
# debug print both actual and expected, showing only expected elements
if max_level == 0:
return ""
if _actual is None:
return f"{indent}** NONE **"
if not hasattr(_actual, "tag") or not hasattr(_expected, "tag"):
return f"{indent}{_actual}"
str_actual = f"{indent}({_actual.tag}"
first_attr = True
for attr in _expected.attrs:
comma = " " if first_attr else ", "
str_actual += f"{comma}{attr}={_debug_value(_actual.attrs.get(attr, '** NOT FOUND **'))}"
first_attr = False
if len(_expected.children) == 0 and len(_actual.children) and max_level > 1:
# force recursion to see sub levels
for _actual_child in _actual.children:
str_child_a = _debug_print_actual(_actual_child, _actual_child, indent + " ", max_level - 1)
str_actual += "\n" + str_child_a if str_child_a else ""
else:
for index, _expected_child in enumerate(_expected.children):
if len(_actual.children) > index:
_actual_child = _actual.children[index]
else:
_actual_child = "** NO MORE CHILDREN **"
str_child_a = _debug_print_actual(_actual_child, _expected_child, indent + " ", max_level - 1)
str_actual += "\n" + str_child_a if str_child_a else ""
str_actual += ")"
return str_actual
def _debug_print_expected(_expected, indent, max_level):
if max_level == 0:
return ""
if _expected is None:
return f"{indent}** NONE **"
if not hasattr(_expected, "tag"):
return f"{indent}{_expected}"
str_expected = f"{indent}({_expected.tag}"
first_attr = True
for attr in _expected.attrs:
comma = " " if first_attr else ", "
str_expected += f"{comma}{attr}={_expected.attrs[attr]}"
first_attr = False
for _expected_child in _expected.children:
str_child_e = _debug_print_expected(_expected_child, indent + " ", max_level - 1)
str_expected += "\n" + str_child_e if str_child_e else ""
str_expected += ")"
return str_expected
if actual is None and expected is not None:
assert False, f"{print_path(path)}actual is None !"
@@ -278,11 +361,11 @@ def matches(actual, expected, path=""):
return True
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
f"{print_path(path)}The types are different: {type(actual)} != {type(expected)}, ({actual} != {expected})."
f"{print_path(path)}The types are different: {type(actual)} != {type(expected)}{_debug(actual, expected)}."
if isinstance(expected, (list, tuple)):
assert len(actual) >= len(expected), \
f"{print_path(path)}Some required elements are missing: {actual} != {expected}."
f"{print_path(path)}Some required elements are missing: {len(actual)=} < {len(expected)}, \n{_debug(actual, expected)}."
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child)
@@ -329,7 +412,7 @@ def matches(actual, expected, path=""):
pass
else:
assert len(actual.children) >= len(expected.children), \
f"{print_path(path)}Some required elements are missing: actual={actual.children} != expected={expected.children}."
f"{print_path(path)}Some required elements are missing: len(actual)={len(actual.children)} < len(expected)={len(expected.children)}{_debug(actual, expected)}."
for actual_child, expected_child in zip(actual.children, expected.children):
matches(actual_child, expected_child, path)
@@ -547,3 +630,11 @@ def icon(name: str):
def div_icon(name: str):
return Div(NotStr(f'<svg name="{name}"'))
def span_icon(name: str):
return Span(NotStr(f'<svg name="{name}"'))
def div_ellipsis(text: str):
return Div(text, cls="truncate", data_tooltip=text)

View File

@@ -11,7 +11,7 @@ from helpers import matches, search_elements_by_path
COLUMNS_SETTINGS_ID = "columns_settings_id"
TEST_GRID_ID = "test_grid_id"
TEST_GRID_KEY = "test_grid_key"
TEST_GRID_KEY = ("RepositoryName", "test_grid_key")
@pytest.fixture()

View File

@@ -5,11 +5,11 @@ import pytest
from fasthtml.components import *
from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.constants import ColumnType, ViewType
from components.datagrid_new.constants import ColumnType, ViewType, FooterAggregation
from components.datagrid_new.settings import DataGridColumnState, DatagridView
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, search_elements_by_path, extract_table_values_new, search_elements_by_name, div_icon, \
Contains
Contains, div_ellipsis
TEST_GRID_ID = "testing_grid_id"
TEST_GRID_KEY = "testing_grid_key"
@@ -20,7 +20,8 @@ def empty_dg(session):
return DataGrid(session,
_id=TEST_GRID_ID,
settings_manager=SettingsManager(MemoryDbEngine()),
key=TEST_GRID_KEY)
key=TEST_GRID_KEY,
boundaries={"height": 500, "width": 800})
@pytest.fixture()
@@ -53,14 +54,18 @@ def test_i_can_render_datagrid(empty_dg):
actual = empty_dg.__ft__()
expected = Div(
Div(id=f"tt_{TEST_GRID_ID}"),
Div(), # menu
Div(
Div(id=f"t_{TEST_GRID_ID}", ), # table
Div(id=f"sb_{TEST_GRID_ID}"), # sidebar
cls="dt2-main",
Div(), # menu
Div(
Div(
Div(id=f"scb_{TEST_GRID_ID}"), # scrollbar
Div(id=f"t_{TEST_GRID_ID}"), # table
id=f"tc_{TEST_GRID_ID}"), # table container
Div(id=f"sb_{TEST_GRID_ID}"), # sidebar
cls="dt2-main",
),
Script()
),
Script(),
id=TEST_GRID_ID
)
@@ -73,15 +78,15 @@ def test_i_can_render_dataframe(dg):
expected = Div(
Div(id=f"tsm_{TEST_GRID_ID}"), # selection manager
Div(id=f"tdd_{TEST_GRID_ID}"), # cell drop down
Div(id=f"tcdd_{TEST_GRID_ID}"), # cell drop down
Div(id=f"tcm_{TEST_GRID_ID}"), # cell drop down
Div(), # Keyboard navigation
Div(
Div(id=f"scb_{TEST_GRID_ID}"), # container for the scroll bars
Div(id=f"th_{TEST_GRID_ID}"), # header
Div(id=f"tb_{TEST_GRID_ID}"), # table
Div(id=f"tf_{TEST_GRID_ID}"), # footer
cls="dt2-inner-table"
)
),
id=f"t_{TEST_GRID_ID}"
)
assert matches(to_compare, expected)
@@ -140,14 +145,13 @@ def test_i_can_render_boolean_cells(dg):
assert matches(to_compare, expected)
def test_i_can_render_when_not_visible(dg):
def test_i_can_render_when_column_not_visible(dg):
updates = [{"col_id": "name", "visible": False}]
dg.update_columns_state(updates)
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"class": "dt2-inner-table"})[0]
expected = Div(
Div(id=f"scb_{TEST_GRID_ID}"),
Div(
Div(data_col='name',
data_tooltip="Show column 'Name'",
@@ -196,14 +200,13 @@ def test_i_can_render_when_not_visible(dg):
assert matches(to_compare, expected)
def test_i_can_render_when_not_usable(dg):
def test_i_can_render_when_column_not_usable(dg):
updates = [{"col_id": "name", "usable": False}]
dg.update_columns_state(updates)
actual = dg.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"class": "dt2-inner-table"})[0]
expected = Div(
Div(id=f"scb_{TEST_GRID_ID}"),
Div(
None,
Div(data_col='age',
@@ -451,4 +454,58 @@ def test_change_view_to_nonexistent_view(dg_with_views):
with pytest.raises(ValueError) as e:
dg_with_views.change_view("Non Existent View")
assert str(e.value) == "View 'Non Existent View' does not exist"
assert str(e.value) == "View 'Non Existent View' does not exist."
def test_i_can_render_footer_menu_emtpy(dg):
menu = dg.mk_footer_menu(None)
expected = Div(
cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm ",
id=f"tcm_{TEST_GRID_ID}",
)
assert matches(menu, expected)
def test_i_can_render_footer_menu_with_items(dg):
boundaries = {"x": 0, "y": 0, "height": 10, "width": 50}
row_index = 0
menu = dg.mk_footer_menu("name", row_index, boundaries) # just give any col_id
expected = Div(
*[Div(
div_ellipsis(agg.value),
cls=Contains("dt2-footer-menu-item"),
) for agg in FooterAggregation],
id=f"tcm_{TEST_GRID_ID}",
)
assert matches(menu, expected)
def test_i_can_compute_footer_menu_position_when_enough_space(dg):
# when enough space at the bottom, the menu is display below the footer cell (below boundaries['y'])
boundaries = {"x": 0, "y": 0, "height": 10, "width": 50}
row_index = 0
menu = dg.mk_footer_menu("name", row_index, boundaries) # just give any col_id
expected = Div(
style=f"left:{boundaries['x'] + 10}px;top:{boundaries['y'] + boundaries['height']}px;",
id=f"tcm_{TEST_GRID_ID}",
)
assert matches(menu, expected)
def test_i_can_compute_footer_menu_position_when_not_enough_space(dg):
# when not enough space at the bottom, the menu is display above the footer cell (above boundaries['y'])
boundaries = {"x": 0, "y": dg.get_state().boundaries["container_height"], "height": 10, "width": 50}
row_index = 0
menu = dg.mk_footer_menu("name", row_index, boundaries) # just give any col_id
expected = Div(
style=f"left:10px;top:296px;",
id=f"tcm_{TEST_GRID_ID}",
)
assert matches(menu, expected)

View File

@@ -1,108 +0,0 @@
import os.path
import pytest
from core.dbengine import DbException
from core.settings_management import DummyDbEngine
from core.settings_objects import BudgetTrackerSettings
settings_file = DummyDbEngine().db_path
FAKE_USER_ID = "FakeUserId"
@pytest.fixture(autouse=True)
def setup_and_finalize():
if os.path.exists(settings_file):
os.remove(settings_file)
yield
if os.path.exists(settings_file):
os.remove(settings_file)
@pytest.fixture()
def engine():
return DummyDbEngine()
def test_i_can_save_and_load(engine):
obj = BudgetTrackerSettings(
spread_sheet="spread_sheet",
col_row_num="row_number",
col_project="project",
col_owner="owner",
col_capex="capex",
col_details="details",
col_supplier="supplier",
col_budget_amt="budget",
col_actual_amt="actual",
col_forecast5_7_amt="forecast5_7",
)
engine.save(FAKE_USER_ID, "MyEntry", obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerSettings)
assert res.spread_sheet == obj.spread_sheet
assert res.col_row_num == obj.col_row_num
assert res.col_project == obj.col_project
assert res.col_owner == obj.col_owner
assert res.col_capex == obj.col_capex
assert res.col_details == obj.col_details
assert res.col_supplier == obj.col_supplier
assert res.col_budget_amt == obj.col_budget_amt
assert res.col_actual_amt == obj.col_actual_amt
assert res.col_forecast5_7_amt == obj.col_forecast5_7_amt
def test_i_can_save_and_modify(engine):
obj = BudgetTrackerSettings()
engine.save(FAKE_USER_ID, "MyEntry", obj)
obj = BudgetTrackerSettings(
spread_sheet="modified_spread_sheet",
col_row_num="modified_row_number",
col_project="modified_project",
col_owner="modified_owner",
col_capex="modified_capex",
col_details="modified_details",
col_supplier="modified_supplier",
col_budget_amt="modified_budget",
col_actual_amt="modified_actual",
col_forecast5_7_amt="forecast5_7",
)
engine.save(FAKE_USER_ID, "MyEntry", obj)
res = engine.load(FAKE_USER_ID, "MyEntry")
assert isinstance(res, BudgetTrackerSettings)
assert res.spread_sheet == obj.spread_sheet
assert res.col_row_num == obj.col_row_num
assert res.col_project == obj.col_project
assert res.col_owner == obj.col_owner
assert res.col_capex == obj.col_capex
assert res.col_details == obj.col_details
assert res.col_supplier == obj.col_supplier
assert res.col_budget_amt == obj.col_budget_amt
assert res.col_actual_amt == obj.col_actual_amt
assert res.col_forecast5_7_amt == obj.col_forecast5_7_amt
def test_i_cannot_load_if_no_setting_file(engine):
with pytest.raises(DbException) as ex:
engine.load(FAKE_USER_ID, "MyEntry")
assert str(ex.value) == f"Entry 'MyEntry' is not found."
def test_i_cannot_load_if_no_entry_found(engine):
obj = BudgetTrackerSettings()
engine.save(FAKE_USER_ID, "AnotherEntry", obj)
with pytest.raises(DbException) as ex:
engine.load(FAKE_USER_ID, "MyEntry")
assert str(ex.value) == f"Entry 'MyEntry' is not found."

View File

@@ -32,13 +32,13 @@ def sample_structure():
@pytest.mark.parametrize("value, expected, expected_error", [
(Div(), "value",
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>, (div((),{}) != value)."),
"The types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=div((),{})\nexpected=value."),
(Div(), A(),
"The elements are different: 'div' != 'a'."),
(Div(Div()), Div(A()),
"Path 'div':\n\tThe elements are different: 'div' != 'a'."),
(Div(A(Span())), Div(A("element")),
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>, (span((),{}) != element)."),
"Path 'div.a':\n\tThe types are different: <class 'fastcore.xml.FT'> != <class 'str'>\nactual=span((),{})\nexpected=element."),
(Div(attr="one"), Div(attr="two"),
"Path 'div':\n\tThe values are different for 'attr' : 'one' != 'two'."),
(Div(A(attr="alpha")), Div(A(attr="beta")),

159
tests/test_jsonviewer.py Normal file
View File

@@ -0,0 +1,159 @@
import pytest
from fasthtml.components import *
from components.debugger.components.JsonViewer import JsonViewer, DictNode, ListNode, ValueNode
from helpers import matches, span_icon, search_elements_by_name
JSON_VIEWER_INSTANCE_ID = "json_viewer"
ML_20 = "margin-left: 20px;"
CLS_PREFIX = "mmt-jsonviewer"
USER_ID = "user_id"
dn = DictNode
ln = ListNode
n = ValueNode
@pytest.fixture()
def json_viewer(session):
return JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, {})
def jv_id(x):
return f"{JSON_VIEWER_INSTANCE_ID}-{x}"
@pytest.mark.parametrize("data, expected_node", [
({}, dn({}, jv_id(0), 0, {})),
([], ln([], jv_id(0), 0, [])),
(1, n(1)),
("value", n("value")),
(True, n(True)),
(None, n(None)),
([1, 2, 3], ln([1, 2, 3], jv_id(0), 0, [n(1), n(2), n(3)])),
({"a": 1, "b": 2}, dn({"a": 1, "b": 2}, jv_id(0), 0, {"a": n(1), "b": n(2)})),
({"a": [1, 2]}, dn({"a": [1, 2]}, jv_id(0), 0, {"a": ln([1, 2], jv_id(1), 1, [n(1), n(2)])})),
([{"a": [1, 2]}],
ln([{"a": [1, 2]}], jv_id(0), 0, [dn({"a": [1, 2]}, jv_id(1), 1, {"a": ln([1, 2], jv_id(2), 2, [n(1), n(2)])})]))
])
def test_i_can_create_node(data, expected_node):
json_viewer_ = JsonViewer(None, JSON_VIEWER_INSTANCE_ID, None, USER_ID, data)
assert json_viewer_.node == expected_node
def test_i_can_render(json_viewer):
actual = json_viewer.__ft__()
expected = Div(
Div(Div(id=f"{jv_id('0')}"), id=f"{jv_id('root')}"), # root debug
cls=f"{CLS_PREFIX}",
id=JSON_VIEWER_INSTANCE_ID)
assert matches(actual, expected)
@pytest.mark.parametrize("value, expected_inner", [
("hello world", Span('"hello world"', cls=f"{CLS_PREFIX}-string")),
(1, Span("1", cls=f"{CLS_PREFIX}-number")),
(True, Span("true", cls=f"{CLS_PREFIX}-bool")),
(False, Span("false", cls=f"{CLS_PREFIX}-bool")),
(None, Span("null", cls=f"{CLS_PREFIX}-null")),
])
def test_i_can_render_simple_value(session, value, expected_inner):
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id("root")}"})[0]
expected = Div(
Div(
None, # no folding
None, # # 'key :' is missing for the first node
expected_inner,
style=ML_20),
id=f"{jv_id("root")}")
assert matches(to_compare, expected)
def test_i_can_render_expanded_list_node(session):
value = [1, "hello", True]
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id("root")}"})[0]
to_compare = to_compare.children[0] # I want to compare what is inside the div
expected_inner = Span("[",
Div(None, Span("0 : "), Span('1'), style=ML_20),
Div(None, Span("1 : "), Span('"hello"'), style=ML_20),
Div(None, Span("2 : "), Span('true'), style=ML_20),
Div("]")),
expected = Div(
span_icon("expanded"),
None, # 'key :' is missing for the first node
expected_inner,
style=ML_20)
assert matches(to_compare, expected)
def test_i_can_render_expanded_dict_node(session):
value = {"a": 1, "b": "hello", "c": True}
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id("root")}"})[0]
to_compare = to_compare.children[0] # I want to compare what is inside the div
expected_inner = Span("{",
Div(None, Span("a : "), Span('1'), style=ML_20),
Div(None, Span("b : "), Span('"hello"'), style=ML_20),
Div(None, Span("c : "), Span('true'), style=ML_20),
Div("}"))
expected = Div(
span_icon("expanded"),
None, # 'key :' is missing for the first node
expected_inner,
style=ML_20)
assert matches(to_compare, expected)
def test_i_can_render_expanded_list_of_dict_node(session):
value = [{"a": 1, "b": "hello"}]
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
actual = jsonv.__ft__()
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id("root")}"})[0]
to_compare = to_compare.children[0] # I want to compare what is inside the div
expected_inner = Span("[",
Div(span_icon("expanded"),
Span("0 : "),
Span("{",
Div(None, Span("a : "), Span('1'), style=ML_20),
Div(None, Span("b : "), Span('"hello"'), style=ML_20),
Div("}")),
id=f"{jv_id(1)}"),
Div("]"))
expected = Div(
span_icon("expanded"),
None, # 'key :' is missing for the first node
expected_inner,
style=ML_20)
assert matches(to_compare, expected)
@pytest.mark.parametrize("input_value, expected_output", [
('Hello World', '"Hello World"'), # No quotes in input
('Hello "World"', "'Hello \"World\"'"), # Contains double quotes
("Hello 'World'", '"Hello \'World\'"'), # Contains single quotes
('Hello "World" and \'Universe\'', '"Hello \\"World\\" and \'Universe\'"'), # both single and double quotes
('', '""'), # Empty string
])
def test_add_quotes(input_value, expected_output):
result = JsonViewer.add_quotes(input_value)
assert result == expected_output

View File

@@ -1,15 +1,14 @@
import pytest
from fasthtml.components import *
from components.addstuff.constants import ROUTE_ROOT, Routes
from components.addstuff.settings import Repository, RepositoriesSettings
from components.repositories.components.Repositories import Repositories
from components.repositories.constants import ROUTE_ROOT, Routes
from core.settings_management import SettingsManager, MemoryDbEngine
from helpers import matches, StartsWith, div_icon, find_first_match, search_elements_by_path
from src.components.addstuff.components.Repositories import Repositories
USER_EMAIL = "test@mail.com"
USER_ID = "test_user"
TEST_REPOSITORIES_ID = "testing_grid_id"
TEST_REPOSITORIES_ID = "testing_repositories_id"
@pytest.fixture
@@ -48,14 +47,9 @@ def tabs_manager():
@pytest.fixture
def db_engine():
return MemoryDbEngine()
@pytest.fixture
def repositories(session, tabs_manager, db_engine):
def repositories(session, tabs_manager):
return Repositories(session=session, _id=TEST_REPOSITORIES_ID,
settings_manager=SettingsManager(engine=db_engine),
settings_manager=SettingsManager(engine=MemoryDbEngine()),
tabs_manager=tabs_manager)
@@ -63,22 +57,18 @@ def test_render_no_repository(repositories):
actual = repositories.__ft__()
expected = (
Div(
Div(id=f"tt_{repositories.get_id()}"),
Div(cls="divider"),
Div("Repositories"),
Div(id=repositories.get_id()),
Script()
)
)
assert matches(actual, expected)
def test_render_when_repo_and_tables(db_engine, repositories):
db_engine.init_db(USER_ID, 'AddStuffSettings', RepositoriesSettings([
Repository("repo 1", [MyTable("table 1"), MyTable("table 2")]),
Repository("repo 2", [MyTable("table 3")]),
]))
def test_render_when_repo_and_tables(repositories):
repositories.db.add_repository("repo 1", ["table 1", "table 2"])
repositories.db.add_repository("repo 2", ["table 3"])
actual = repositories.__ft__()
to_compare = search_elements_by_path(actual, "div", {"id": repositories.get_id()})[0]
@@ -109,8 +99,9 @@ def test_i_can_add_new_repository(repositories):
form_id = "form_id"
repository_name = "repository_name"
table_name = "table_name"
boundaries = {"height": 600, "width": 800}
res = repositories.add_new_repository(tab_id, form_id, repository_name, table_name)
res = repositories.add_new_repository(tab_id, form_id, repository_name, table_name, boundaries)
expected = (
Div(
Input(type="radio"),
@@ -125,10 +116,8 @@ def test_i_can_add_new_repository(repositories):
assert matches(res, expected)
def test_i_can_click_on_repo(db_engine, repositories):
db_engine.init_db(USER_ID, 'AddStuffSettings', RepositoriesSettings([
Repository("repo 1", [])
]))
def test_i_can_click_on_repo(repositories):
repositories.db.add_repository("repo 1", [])
actual = repositories.__ft__()
expected = Input(
@@ -140,17 +129,15 @@ def test_i_can_click_on_repo(db_engine, repositories):
assert matches(to_compare, expected)
def test_render_i_can_click_on_table(db_engine, repositories, tabs_manager):
db_engine.init_db(USER_ID, 'AddStuffSettings', RepositoriesSettings([
Repository("repo 1", [MyTable("table 1")])
]))
def test_render_i_can_click_on_table(repositories, tabs_manager):
repositories.db.add_repository("repo 1", ["table 1"])
actual = repositories.__ft__()
expected = Div(name="repo-table",
hx_get=f"{ROUTE_ROOT}{Routes.ShowTable}",
hx_target=f"#{repositories.tabs_manager.get_id()}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{repositories.get_id()}", "repository": "repo 1", "table": "table 1"}}',
hx_vals=f'js:{{"_id": "{repositories.get_id()}", "repository": "repo 1", "table": "table 1", "tab_boundaries": getTabContentBoundaries("tabs_id")}}',
cls="flex")
to_compare = find_first_match(actual, "div.div.div.div.div[name='repo-table']")

View File

@@ -1,33 +1,44 @@
import pytest
from components.addstuff.settings import RepositoriesDbManager, RepositoriesSettings, Repository, \
from components.repositories.db_management import RepositoriesDbManager, RepositoriesSettings, Repository, \
REPOSITORIES_SETTINGS_ENTRY
from core.settings_management import SettingsManager, MemoryDbEngine
@pytest.fixture
def settings_manager():
return SettingsManager(MemoryDbEngine())
return SettingsManager(MemoryDbEngine())
@pytest.fixture
def db(session, settings_manager):
return RepositoriesDbManager(session, settings_manager)
@pytest.fixture
def settings_manager_with_existing_repo(db, settings_manager):
settings = RepositoriesSettings()
repo = Repository(name="ExistingRepo", tables=["Table1"])
settings.repositories.append(repo)
settings_manager.save(db.session, REPOSITORIES_SETTINGS_ENTRY, settings)
return settings_manager
def test_add_new_repository(db, settings_manager):
"""Test adding a new repository with valid data."""
db.add_repository("NewRepo", ["Table1", "Table2"])
settings = settings_manager.get(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert settings.repositories[0].name == "NewRepo"
assert settings.repositories[0].tables == ["Table1", "Table2"]
"""Test adding a new repository with valid data."""
db.add_repository("NewRepo", ["Table1", "Table2"])
settings = settings_manager.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert settings.repositories[0].name == "NewRepo"
assert settings.repositories[0].tables == ["Table1", "Table2"]
def test_add_repository_duplicate_name(db, settings_manager):
"""Test adding a repository with an existing name."""
settings = RepositoriesSettings()
settings.repositories.append(Repository(name="ExistingRepo", tables=[]))
settings_manager.put(db.session, REPOSITORIES_SETTINGS_ENTRY, settings)
settings_manager.save(db.session, REPOSITORIES_SETTINGS_ENTRY, settings)
with pytest.raises(ValueError, match="Repository 'ExistingRepo' already exists."):
db.add_repository("ExistingRepo")
@@ -49,30 +60,25 @@ def test_add_repository_no_tables(db, settings_manager):
"""Test adding a repository without specifying tables."""
db.add_repository("RepoWithoutTables")
settings = settings_manager.get(db.session, "Repositories")
settings = settings_manager.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert settings.repositories[0].name == "RepoWithoutTables"
assert settings.repositories[0].tables == []
def test_get_existing_repository(db, settings_manager_with_existing_repo):
"""Test retrieving an existing repository."""
# Retrieve the repository
retrieved_repo = db.get_repository("ExistingRepo")
def test_get_existing_repository(db, settings_manager):
"""Test retrieving an existing repository."""
# Pre-populate settings with a repository
settings = RepositoriesSettings()
repo = Repository(name="ExistingRepo", tables=["Table1"])
settings.repositories.append(repo)
settings_manager.put(db.session, "Repositories", settings)
# Retrieve the repository
retrieved_repo = db.get_repository("ExistingRepo")
# Verify the repository is correctly returned
assert retrieved_repo.name == "ExistingRepo"
assert retrieved_repo.tables == ["Table1"]
# Verify the repository is correctly returned
assert retrieved_repo.name == "ExistingRepo"
assert retrieved_repo.tables == ["Table1"]
def test_get_repository_not_found(db):
"""Test retrieving a repository that does not exist."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exists."):
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exist."):
db.get_repository("NonExistentRepo")
@@ -87,3 +93,183 @@ def test_get_repository_none_name(db):
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.get_repository(None)
def test_modify_repository_valid(db, settings_manager_with_existing_repo):
"""Test modifying an existing repository with valid data."""
modified_repo = db.modify_repository("ExistingRepo", "ModifiedRepo", ["UpdatedTable1", "UpdatedTable2"])
assert modified_repo.name == "ModifiedRepo"
assert modified_repo.tables == ["UpdatedTable1", "UpdatedTable2"]
updated_settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(updated_settings.repositories) == 1
assert updated_settings.repositories[0].name == "ModifiedRepo"
assert updated_settings.repositories[0].tables == ["UpdatedTable1", "UpdatedTable2"]
def test_modify_repository_not_found(db):
"""Test modifying a repository that does not exist."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' not found."):
db.modify_repository("NonExistentRepo", "NewName", ["Table1"])
@pytest.mark.parametrize("repo_to_modify, new_repo", [
("", "NewName"),
(None, "NewName"),
("ExistingRepo", ""),
("ExistingRepo", None),
])
def test_modify_repository_empty_repo_to_modify(db, repo_to_modify, new_repo):
"""Test modifying a repository with an empty name for repo_to_modify."""
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.modify_repository(repo_to_modify, new_repo, ["Table1"])
def test_modify_repository_empty_tables_list(db, settings_manager_with_existing_repo):
"""Test modifying an existing repository to have an empty list of tables."""
modified_repo = db.modify_repository("ExistingRepo", "RepoWithEmptyTables", [])
assert modified_repo.name == "RepoWithEmptyTables"
assert modified_repo.tables == []
updated_settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(updated_settings.repositories) == 1
assert updated_settings.repositories[0].name == "RepoWithEmptyTables"
assert updated_settings.repositories[0].tables == []
def test_remove_repository_success(db, settings_manager_with_existing_repo):
"""Test successfully removing an existing repository."""
db.remove_repository("ExistingRepo")
settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 0
def test_remove_repository_not_found(db):
"""Test removing a repository that does not exist."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exist."):
db.remove_repository("NonExistentRepo")
def test_remove_repository_empty_name(db):
"""Test removing a repository with an empty name."""
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.remove_repository("")
def test_remove_repository_none_name(db):
"""Test removing a repository with a None name."""
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.remove_repository(None)
def test_add_table_success(db, settings_manager_with_existing_repo):
"""Test successfully adding a new table to an existing repository."""
db.add_table("ExistingRepo", "NewTable")
settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert "NewTable" in settings.repositories[0].tables
assert "Table1" in settings.repositories[0].tables
def test_add_table_repository_not_found(db):
"""Test adding a table to a non-existent repository."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exist."):
db.add_table("NonExistentRepo", "NewTable")
def test_add_table_empty_name(db, settings_manager_with_existing_repo):
"""Test adding a table with an empty name."""
with pytest.raises(ValueError, match="Table name cannot be empty."):
db.add_table("ExistingRepo", "")
def test_add_table_none_name(db, settings_manager_with_existing_repo):
"""Test adding a table with a None name."""
with pytest.raises(ValueError, match="Table name cannot be empty."):
db.add_table("ExistingRepo", None)
def test_add_table_duplicate(db, settings_manager_with_existing_repo):
"""Test adding a duplicate table name."""
with pytest.raises(ValueError, match="Table 'Table1' already exists in repository 'ExistingRepo'."):
db.add_table("ExistingRepo", "Table1")
def test_modify_table_success(db, settings_manager_with_existing_repo):
"""Test successfully modifying an existing table."""
db.modify_table("ExistingRepo", "Table1", "ModifiedTable")
settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert "ModifiedTable" in settings.repositories[0].tables
assert "Table1" not in settings.repositories[0].tables
def test_modify_table_repository_not_found(db):
"""Test modifying a table in a non-existent repository."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exist."):
db.modify_table("NonExistentRepo", "Table1", "NewTable")
def test_modify_table_not_found(db, settings_manager_with_existing_repo):
"""Test modifying a non-existent table."""
with pytest.raises(ValueError, match="Table 'NonExistentTable' does not exist in repository 'ExistingRepo'."):
db.modify_table("ExistingRepo", "NonExistentTable", "NewTable")
@pytest.mark.parametrize("old_table, new_table", [
("", "NewTable"),
(None, "NewTable"),
("Table1", ""),
("Table1", None),
])
def test_modify_table_empty_names(db, settings_manager_with_existing_repo, old_table, new_table):
"""Test modifying a table with empty/None names."""
with pytest.raises(ValueError, match="Table name cannot be empty."):
db.modify_table("ExistingRepo", old_table, new_table)
def test_modify_table_empty_repository_name(db):
"""Test modifying a table with empty/None names."""
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.modify_table(None, "old_table", "new_table")
def test_remove_table_success(db, settings_manager_with_existing_repo):
"""Test successfully removing an existing table."""
db.remove_table("ExistingRepo", "Table1")
settings = settings_manager_with_existing_repo.load(db.session, REPOSITORIES_SETTINGS_ENTRY)
assert len(settings.repositories) == 1
assert "Table1" not in settings.repositories[0].tables
def test_remove_table_repository_not_found(db):
"""Test removing a table from a non-existent repository."""
with pytest.raises(ValueError, match="Repository 'NonExistentRepo' does not exist."):
db.remove_table("NonExistentRepo", "Table1")
def test_remove_table_not_found(db, settings_manager_with_existing_repo):
"""Test removing a non-existent table."""
with pytest.raises(ValueError, match="Table 'NonExistentTable' does not exist in repository 'ExistingRepo'."):
db.remove_table("ExistingRepo", "NonExistentTable")
def test_remove_table_empty_name(db, settings_manager_with_existing_repo):
"""Test removing a table with empty/None name."""
with pytest.raises(ValueError, match="Table name cannot be empty."):
db.remove_table("ExistingRepo", "")
with pytest.raises(ValueError, match="Table name cannot be empty."):
db.remove_table("ExistingRepo", None)
def test_remove_table_empty_repository_name(db):
"""Test removing a table with empty/None repository name."""
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.remove_table("", "Table1")
with pytest.raises(ValueError, match="Repository name cannot be empty."):
db.remove_table(None, "Table1")

View File

@@ -1,116 +1,88 @@
from unittest.mock import MagicMock
import dataclasses
import pytest
from core.settings_management import SettingsManager, DummyDbEngine
from core.settings_objects import BudgetTrackerSettings, BudgetTrackerMappings, BUDGET_TRACKER_MAPPINGS_ENTRY
from core.settings_management import SettingsManager, MemoryDbEngine
FAKE_USER_ID = "FakeUserId"
@dataclasses.dataclass
class DummyObject:
a: int
b: str
c: bool
@dataclasses.dataclass
class DummySettings:
prop1: DummyObject
prop2: str
@pytest.fixture()
def manager():
return SettingsManager(DummyDbEngine("settings_from_unit_testing.json"))
return SettingsManager(MemoryDbEngine())
def test_i_can_save_and_load_settings(manager):
settings = BudgetTrackerSettings()
manager.save(FAKE_USER_ID, "MyEntry", settings)
from_db = manager.load(FAKE_USER_ID, "MyEntry")
assert isinstance(from_db, BudgetTrackerSettings)
assert from_db.spread_sheet == settings.spread_sheet
assert from_db.col_row_num == settings.col_row_num
assert from_db.col_project == settings.col_project
assert from_db.col_owner == settings.col_owner
assert from_db.col_capex == settings.col_capex
assert from_db.col_details == settings.col_details
assert from_db.col_supplier == settings.col_supplier
assert from_db.col_budget_amt == settings.col_budget_amt
assert from_db.col_actual_amt == settings.col_actual_amt
assert from_db.col_forecast5_7_amt == settings.col_forecast5_7_amt
@pytest.fixture()
def settings():
return DummySettings(
prop1=DummyObject(1, "2", True),
prop2="prop2_new",
)
@pytest.fixture
def mock_db_engine():
"""Fixture to mock the _db_engine instance."""
return MagicMock()
def test_i_can_save_and_load_settings(session, manager, settings):
manager.save(session, "MyEntry", settings)
from_db = manager.load(session, "MyEntry")
assert isinstance(from_db, DummySettings)
assert from_db.prop1.a == 1
assert from_db.prop1.b == "2"
assert from_db.prop1.c == True
assert from_db.prop2 == "prop2_new"
@pytest.fixture
def settings_manager(mock_db_engine):
"""Fixture to provide an instance of SettingsManager with a mocked db engine."""
return SettingsManager(engine=mock_db_engine)
def test_i_can_have_two_entries(session, manager, settings):
manager.save(session, "MyEntry", settings)
manager.save(session, "MyOtherEntry", settings)
from_db = manager.load(session, "MyEntry")
from_db_other = manager.load(session, "MyOtherEntry")
assert isinstance(from_db, DummySettings)
assert isinstance(from_db_other, DummySettings)
def test_get_successful(settings_manager, mock_db_engine):
"""Test successful retrieval of a value."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.return_value = "mock_value"
# Act
result = settings_manager.get(session=session, key="theme")
# Assert
assert result == "mock_value"
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", "theme")
def test_i_can_put_many_items_dict(session, manager):
manager.save(session, "TestEntry", {})
items = {
'key1': 'value1',
'key2': 'value2',
'key3': 'value3'
}
manager.put_many(session, "TestEntry", items)
loaded = manager.load(session, "TestEntry")
assert loaded['key1'] == 'value1'
assert loaded['key2'] == 'value2'
assert loaded['key3'] == 'value3'
def test_get_key_error_no_default(settings_manager, mock_db_engine):
"""Test KeyError is raised if key doesn't exist and default is NoDefault."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.side_effect = KeyError # Simulate missing key
# Act & Assert
with pytest.raises(KeyError):
settings_manager.get(session=session, key="theme")
def test_get_key_error_with_default(settings_manager, mock_db_engine):
"""Test default value is returned if key doesn't exist and default is provided."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.side_effect = KeyError # Simulate missing key
# Act
result = settings_manager.get(session=session, key="theme", default="default_value")
# Assert
assert result == "default_value"
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", "theme")
def test_get_key_none(settings_manager, mock_db_engine):
"""Test behavior when key is None."""
# Arrange
session = {"user_id": "user123", "user_email": "user@example.com"}
mock_db_engine.get.return_value = {"example_key": "example_value"}
# Act
result = settings_manager.get(session=session, key=None)
# Assert
assert result == {"example_key": "example_value"}
mock_db_engine.get.assert_called_once_with("user@example.com", "user123", None)
#
# def test_i_can_save_and_load_mapping_settings(manager):
# """
# I test 'BudgetTrackerMappings' because there is an object inside an object
# :param manager:
# :return:
# """
# settings = BudgetTrackerMappings(mappings=[
# BudgetTrackerMappings.Mapping(1, "p1", "o1", "d1", "s1", "l1_1", "l2_1", "l3_1", 0),
# BudgetTrackerMappings.Mapping(1, "p2", "o2", "d2", "s2", "l1_2", "l2_2", "l3_2", 10)])
#
# manager.save(FAKE_USER_ID, BUDGET_TRACKER_MAPPINGS_ENTRY, settings)
# from_db = manager.load(FAKE_USER_ID, BUDGET_TRACKER_MAPPINGS_ENTRY)
#
# assert isinstance(from_db, BudgetTrackerMappings)
# assert len(from_db.mappings) == 2
# assert isinstance(from_db.mappings[0], BudgetTrackerMappings.Mapping)
# assert from_db.mappings[0].col_project == "p1"
# assert from_db.mappings[1].col_project == "p2"
def test_i_can_put_many_items_list(session, manager):
manager.save(session, "TestEntry", {})
items = [
('key1', 'value1'),
('key2', 'value2'),
('key3', 'value3')
]
manager.put_many(session, "TestEntry", items)
loaded = manager.load(session, "TestEntry")
assert loaded['key1'] == 'value1'
assert loaded['key2'] == 'value2'
assert loaded['key3'] == 'value3'

View File

@@ -4,7 +4,7 @@ from fasthtml.components import *
from components.tabs.components.MyTabs import Tab, MyTabs
from components.tabs.constants import ROUTE_ROOT, Routes
from tests.helpers import matches, find_first_match
from tests.helpers import matches, find_first_match, search_elements_by_name, div_ellipsis
@pytest.fixture
@@ -105,6 +105,7 @@ def test_add_tab_with_icon_attribute(tabs_instance):
assert tabs_instance.tabs[0].id == tab_id
assert tabs_instance.tabs[0].icon == icon
def test_remove_tab(tabs_instance):
"""Test the remove_tab method."""
# Add some tabs
@@ -171,41 +172,48 @@ def test_do_no_change_the_active_tab_if_another_tab_is_removed(tabs_instance):
def test_render_empty_when_empty(tabs_instance):
expected = Div(id=tabs_instance._id)
actual = tabs_instance.__ft__()
assert matches(tabs_instance.__ft__(), expected)
expected = Div(id=tabs_instance._id)
assert matches(actual, expected)
def test_render_empty_when_multiple_tabs(tabs_instance):
def test_render_when_multiple_tabs(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
Tab("2", "Tab2", "Content 2", active=True),
Tab("3", "Tab3", "Content 3"),
]
actual = tabs_instance.__ft__()
to_compare = search_elements_by_name(actual, "div", {"id": tabs_instance._id})
expected = Div(
Span(cls="tabs-tab "),
Span(cls="tabs-tab tabs-active"),
Span(cls="tabs-tab "),
Div("Content 2", cls="tabs-content"),
Div(
Span(cls="mmt-tabs-tab "),
Span(cls="mmt-tabs-tab mmt-tabs-active"),
Span(cls="mmt-tabs-tab "),
cls="mmt-tabs-header"
),
Div("Content 2", cls="mmt-tabs-content"),
id=tabs_instance._id,
cls="tabs",
cls="mmt-tabs",
)
actual = tabs_instance.__ft__()
assert matches(actual, expected)
assert matches(to_compare[0], expected)
def test_render_a_tab_has_label_and_a_cross_with_correct_hx_posts(tabs_instance):
def test_render_a_tab_header_with_its_name_and_the_cross_to_close(tabs_instance):
tabs_instance.tabs = [
Tab("1", "Tab1", "Content 1"),
]
actual = tabs_instance.__ft__()
expected = Span(
Label("Tab1", hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}"),
Label(div_ellipsis("Tab1"), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}"),
Div(NotStr('<svg name="close"'), hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
cls="tabs-tab "
cls="mmt-tabs-tab "
)
actual = find_first_match(tabs_instance.__ft__(), "div.span")
assert matches(actual, expected)
to_compare = find_first_match(actual, "div.div.span")
assert matches(to_compare, expected)