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:
24
Dockerfile
Normal file
24
Dockerfile
Normal 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"]
|
||||
27
README.md
27
README.md
@@ -7,4 +7,31 @@ using [FastHTML](https://www.fastht.ml/) to serve the front end
|
||||
```commandline
|
||||
cd src
|
||||
python main.py
|
||||
```
|
||||
|
||||
# Using Docker
|
||||
1. **Build and start the services**:
|
||||
```shell
|
||||
docker-compose up -d
|
||||
```
|
||||
The application will be accessible on port 8000 (or whatever port you configured).
|
||||
|
||||
2. **Initialize the Mistral model** (first run):
|
||||
```shell
|
||||
docker-compose exec ollama ollama pull mistral:7b-instruct
|
||||
```
|
||||
|
||||
1. **Check logs**:
|
||||
```shell
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
1. **Stop the services**:
|
||||
```shell
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
1. **Rebuild**:
|
||||
```shell
|
||||
docker-compose build
|
||||
```
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal 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:
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
""")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
36
src/components/BaseCommandManager.py
Normal file
36
src/components/BaseCommandManager.py
Normal file
@@ -0,0 +1,36 @@
|
||||
class BaseCommandManager:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
|
||||
@staticmethod
|
||||
def merge(*items):
|
||||
"""
|
||||
Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
|
||||
If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
|
||||
If the key's value is not a string, an error is raised.
|
||||
|
||||
:param items: dictionaries to be merged. If all items are None, None is returned.
|
||||
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
|
||||
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
|
||||
"""
|
||||
if all(item is None for item in items):
|
||||
return None
|
||||
|
||||
res = {}
|
||||
for item in [item for item in items if item is not None]:
|
||||
|
||||
for key, value in item.items():
|
||||
if not key in res:
|
||||
res[key] = value
|
||||
else:
|
||||
if isinstance(res[key], str):
|
||||
res[key] += " " + value
|
||||
else:
|
||||
raise NotImplementedError("")
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def merge_class(cls1, cls2):
|
||||
return (cls1 + " " + cls2) if cls2 else cls1
|
||||
@@ -10,6 +10,9 @@ class BaseComponent:
|
||||
def get_id(self):
|
||||
return self._id
|
||||
|
||||
def get_session(self):
|
||||
return self._session
|
||||
|
||||
def __repr__(self):
|
||||
return self._id
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
function bindRepositories(repositoryId) {
|
||||
bindTooltipsWithDelegation(repositoryId)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
from components.addstuff.constants import ROUTE_ROOT, Routes
|
||||
|
||||
|
||||
class Commands:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
|
||||
def request_add_table(self, repository_name):
|
||||
return {
|
||||
"hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
|
||||
}
|
||||
|
||||
def add_table(self):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
# The repository_name and the table_name will be given by the form
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ROUTE_ROOT, Routes
|
||||
from components.addstuff.settings import RepositoriesDbManager
|
||||
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
|
||||
from components.repositories.components.Repositories import Repositories
|
||||
from core.instance_manager import InstanceManager
|
||||
|
||||
|
||||
class AddStuffMenu(BaseComponent):
|
||||
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
||||
super().__init__(session, _id)
|
||||
self.tabs_manager = tabs_manager # MyTabs component id
|
||||
self.mappings = {} # to keep track of when element is displayed on which tab
|
||||
self.settings = RepositoriesDbManager(session, settings_manager)
|
||||
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
Div("Add stuff...", tabindex="0"),
|
||||
Ul(
|
||||
Li(A("Add Database",
|
||||
hx_get=f"{ROUTE_ROOT}{Routes.AddRepository}",
|
||||
hx_target=f"#{self.tabs_manager.get_id()}",
|
||||
hx_swap="outerHTML",
|
||||
)),
|
||||
Li(A("Add Database", **self.repositories.commands.request_add_repository())),
|
||||
Li(A("Add Application")),
|
||||
tabindex="0",
|
||||
cls="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm"
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
|
||||
ADD_DATABASE_INSTANCE_ID = "__AddDatabase__"
|
||||
REPOSITORIES_INSTANCE_ID = "__Repositories__"
|
||||
ROUTE_ROOT = "/add"
|
||||
|
||||
class Routes:
|
||||
AddRepository = "/add-repository"
|
||||
SelectRepository = "/select-repository"
|
||||
AddTable = "/add-table"
|
||||
ShowTable = "/show-table"
|
||||
@@ -1,112 +0,0 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from core.settings_management import SettingsManager
|
||||
from core.settings_objects import BaseSettingObj
|
||||
|
||||
ADD_STUFF_SETTINGS_ENTRY = "AddStuffSettings"
|
||||
REPOSITORIES_SETTINGS_ENTRY = "Repositories"
|
||||
|
||||
logger = logging.getLogger("AddStuffSettings")
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Repository:
|
||||
name: str
|
||||
tables: list[str]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RepositoriesSettings:
|
||||
repositories: list[Repository] = dataclasses.field(default_factory=list)
|
||||
selected_repository_name: str = None
|
||||
|
||||
|
||||
class RepositoriesDbManager:
|
||||
|
||||
def __init__(self, session: dict, settings_manager: SettingsManager):
|
||||
self.session = session
|
||||
self.settings_manager = settings_manager
|
||||
|
||||
def _get_settings(self):
|
||||
return self.settings_manager.get(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
|
||||
|
||||
def add_repository(self, repository_name: str, tables: list[str] = None):
|
||||
"""
|
||||
Adds a new repository to the list of repositories. The repository is identified
|
||||
by its name and can optionally include a list of associated tables.
|
||||
|
||||
:param repository_name: The name of the repository to be added.
|
||||
:param tables: A list of Table objects to be associated with the repository,
|
||||
defaulting to an empty list if not provided.
|
||||
:type tables: list[Table], optional
|
||||
:return: None
|
||||
"""
|
||||
|
||||
settings = self._get_settings()
|
||||
|
||||
if repository_name is None or repository_name == "":
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
if repository_name in [repo.name for repo in settings.repositories]:
|
||||
raise ValueError(f"Repository '{repository_name}' already exists.")
|
||||
|
||||
existing_repositories = [r.name for r in settings.repositories]
|
||||
logger.info(f"Existing repositories:{existing_repositories}")
|
||||
|
||||
repository = Repository(repository_name, tables or [])
|
||||
settings.repositories.append(repository)
|
||||
self.settings_manager.put(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
return repository
|
||||
|
||||
def get_repository(self, repository_name: str):
|
||||
if repository_name is None or repository_name == "":
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
settings = self._get_settings()
|
||||
if repository_name not in [repo.name for repo in settings.repositories]:
|
||||
raise ValueError(f"Repository '{repository_name}' does not exists.")
|
||||
|
||||
return next(filter(lambda r: r.name == repository_name, settings.repositories))
|
||||
|
||||
def modify_repository(self, repository_name: str, tables: list[str]):
|
||||
repository = self.get_repository(repository_name)
|
||||
|
||||
|
||||
def get_repositories(self):
|
||||
return self._get_settings().repositories
|
||||
|
||||
def add_table(self, repository_name: str, table_name: str, table_settings: dict):
|
||||
"""
|
||||
Adds a table to the specified repository
|
||||
|
||||
:param repository_name: The name of the target repository.
|
||||
:param table_name: The name of the table to add.
|
||||
:param table_settings: A dictionary containing the settings or configuration details
|
||||
of the table.
|
||||
:return: None
|
||||
"""
|
||||
settings = self._get_settings()
|
||||
|
||||
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
|
||||
if repository is None:
|
||||
raise ValueError(f"Repository '{repository_name}' does not exists.")
|
||||
|
||||
if table_name in (t.name for t in repository.tables):
|
||||
raise ValueError(f"Table '{table_name}' already exists.")
|
||||
|
||||
repository.tables.append(MyTable(table_name, table_settings))
|
||||
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
|
||||
|
||||
def select_repository(self, repository_name: str):
|
||||
"""
|
||||
Select and save the specified repository name in the current session's settings.
|
||||
|
||||
:param repository_name: The name of the repository to be selected and stored.
|
||||
:type repository_name: str
|
||||
:return: None
|
||||
"""
|
||||
settings = self._get_settings()
|
||||
settings.selected_repository_name = repository_name
|
||||
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
|
||||
|
||||
@@ -11,7 +11,7 @@ from components.datagrid.DataGridCommandManager import DataGridCommandManager
|
||||
from components.datagrid.constants import *
|
||||
from components.datagrid.icons import *
|
||||
from core.utils import append_once, from_bool
|
||||
from core.utils import make_html_id, make_column_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
|
||||
from core.utils import make_html_id, make_safe_id, snake_case_to_capitalized_words, get_sheets_names, to_bool
|
||||
|
||||
logger = logging.getLogger("DataGrid")
|
||||
|
||||
@@ -1697,16 +1697,16 @@ class DataGrid:
|
||||
|
||||
if (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is not None:
|
||||
res[DG_COLUMNS] = {
|
||||
make_column_id(col): {INDEX_KEY: index,
|
||||
TITLE_KEY: col,
|
||||
make_safe_id(col): {INDEX_KEY: index,
|
||||
TITLE_KEY: col,
|
||||
"type": dtype_mapping.get(dtype.name, DG_DATATYPE_STRING), }
|
||||
for index, (col, dtype) in enumerate(zip(df.columns, df.dtypes))
|
||||
}
|
||||
|
||||
elif (DG_COLUMNS not in res or res[DG_COLUMNS] is None) and df is None and columns is not None:
|
||||
res[DG_COLUMNS] = {
|
||||
make_column_id(col): {INDEX_KEY: index,
|
||||
TITLE_KEY: snake_case_to_capitalized_words(col)}
|
||||
make_safe_id(col): {INDEX_KEY: index,
|
||||
TITLE_KEY: snake_case_to_capitalized_words(col)}
|
||||
for index, col in enumerate(columns)
|
||||
}
|
||||
|
||||
@@ -1717,7 +1717,7 @@ class DataGrid:
|
||||
if isinstance(res[DG_COLUMNS], list):
|
||||
# set grid settings from list
|
||||
res[DG_COLUMNS] = {
|
||||
make_column_id(col): {INDEX_KEY: index, TITLE_KEY: col}
|
||||
make_safe_id(col): {INDEX_KEY: index, TITLE_KEY: col}
|
||||
for index, col in enumerate(res[DG_COLUMNS])
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,13 @@ def post(session, _id: str, key: str, arg: str = None):
|
||||
|
||||
|
||||
@rt(Routes.OnClick)
|
||||
def post(session, _id: str, cell_id: str = None, modifier: str = None):
|
||||
logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}")
|
||||
def post(session, _id: str, cell_id: str = None, modifier: str = None, boundaries: str=None):
|
||||
logger.debug(f"Entering on_click with args {_id=}, {cell_id=}, {modifier=}, {boundaries=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.manage_click(cell_id, modifier)
|
||||
return instance.manage_click(cell_id, modifier, json.loads(boundaries) if boundaries else None)
|
||||
|
||||
@rt(Routes.UpdateState)
|
||||
def post(session, _id: str, state: str, args: str = None):
|
||||
logger.debug(f"Entering on_state_changed with args {_id=}, {state=}, {args=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.manage_state_changed(state, args)
|
||||
@@ -4,16 +4,18 @@
|
||||
|
||||
using `Datagrid(id=my_id)`
|
||||
|
||||
| Name | value |
|
||||
|--------------------------------|----------------------------------------------------------------|
|
||||
| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
|
||||
| filter all | `fa_{datagrid_id}` |
|
||||
| file upload | `fu_{datagrid_id}` |
|
||||
| sidebar | `sb_{datagrid_id}` |
|
||||
| scroll bars | `scb_{datagrid_id}` |
|
||||
| Settings columns | `scol_{datagrid_id}` |
|
||||
| table | `t_{datagrid_id}` |
|
||||
| table cell drop down | `tcdd_{datagrid_id}` |
|
||||
| table drag and drop info | `tdd_{datagrid_id}` |
|
||||
| views selection component | `v_{datagrid_id}` |
|
||||
| Name | value |
|
||||
|---------------------------|----------------------------------------------------------------|
|
||||
| datagrid object | `get_unique_id(f"{DATAGRID_INSTANCE_ID}{session['user_id']}")` |
|
||||
| filter all | `fa_{datagrid_id}` |
|
||||
| file upload | `fu_{datagrid_id}` |
|
||||
| footer menu | `fm_{datagrid_id}` |
|
||||
| sidebar | `sb_{datagrid_id}` |
|
||||
| scroll bars | `scb_{datagrid_id}` |
|
||||
| Settings columns | `scol_{datagrid_id}` |
|
||||
| table | `t_{datagrid_id}` |
|
||||
| table container | `tc_{datagrid_id}` |
|
||||
| table cell menu | `tcm_{datagrid_id}` |
|
||||
| table drag and drop info | `tdd_{datagrid_id}` |
|
||||
| views selection component | `v_{datagrid_id}` |
|
||||
|
||||
@@ -6,7 +6,7 @@ input:focus {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
z-index: 5;
|
||||
z-index: var(--datagrid-drag-drop-zindex);
|
||||
width: 100px;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 10px;
|
||||
@@ -20,8 +20,8 @@ input:focus {
|
||||
}
|
||||
|
||||
.dt2-main {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dt2-sidebar {
|
||||
@@ -46,80 +46,77 @@ input:focus {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.dt2-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dt2-scrollbars {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
pointer-events: none; /* Ensures parents don't intercept pointer events */
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
pointer-events: none; /* Ensures parents don't intercept pointer events */
|
||||
z-index: var(--datagrid-scrollbars-zindex);
|
||||
}
|
||||
|
||||
/* Scrollbar Wrappers */
|
||||
.dt2-scrollbars-vertical-wrapper {
|
||||
bottom: 3px;
|
||||
left: auto;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
width: 8px;
|
||||
background-color: var(--color-base-200)
|
||||
}
|
||||
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
bottom: -12px;
|
||||
height: 8px;
|
||||
left: 3px;
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: auto;
|
||||
background-color: var(--color-base-200)
|
||||
}
|
||||
|
||||
/* Vertical Scrollbar */
|
||||
.dt2-scrollbars-vertical {
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: auto;
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 3px; /* Rounded corners */
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
cursor: pointer;
|
||||
width: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Horizontal Scrollbar */
|
||||
.dt2-scrollbars-horizontal {
|
||||
bottom: 0;
|
||||
left: auto;
|
||||
position: absolute;
|
||||
right: auto;
|
||||
top: 0;
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 3px; /* Rounded corners */
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
cursor: pointer;
|
||||
height: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Scrollbar wrappers are hidden by default */
|
||||
/* Scrollbar Wrappers common attributes*/
|
||||
.dt2-scrollbars-vertical-wrapper,
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
position: absolute;
|
||||
background-color: var(--color-base-200)
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
|
||||
pointer-events: auto; /* Allow interaction */
|
||||
}
|
||||
|
||||
/* Scrollbar Wrappers */
|
||||
.dt2-scrollbars-vertical-wrapper {
|
||||
left: auto;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
top: auto;
|
||||
bottom: -12px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: var(--color-resize);
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 3px;
|
||||
pointer-events: auto; /* Allow interaction with the scrollbar */
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
border-radius: 3px; /* Rounded corners */
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Vertical Scrollbar */
|
||||
.dt2-scrollbars-vertical {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
width: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Horizontal Scrollbar */
|
||||
.dt2-scrollbars-horizontal {
|
||||
left: auto;
|
||||
right: auto;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Scrollbar hover effects */
|
||||
@@ -130,7 +127,6 @@ input:focus {
|
||||
background-color: var(--color-base-content);
|
||||
}
|
||||
|
||||
|
||||
.dt2-table {
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
@@ -153,7 +149,6 @@ input:focus {
|
||||
}
|
||||
|
||||
.dt2-body {
|
||||
max-height: 650px;
|
||||
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
|
||||
font-size: 14px;
|
||||
min-width: max-content;
|
||||
@@ -200,6 +195,36 @@ input:focus {
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.dt2-footer-cell {
|
||||
cursor : pointer
|
||||
}
|
||||
|
||||
.dt2-footer-menu {
|
||||
position: absolute;
|
||||
display: None;
|
||||
z-index: var(--datagrid-menu-zindex);
|
||||
border: 1px solid oklch(var(--b3));
|
||||
box-sizing: border-box;
|
||||
width: 80px;
|
||||
background-color: var(--color-base-100); /* Add background color */
|
||||
opacity: 1; /* Ensure full opacity */
|
||||
}
|
||||
|
||||
.dt2-footer-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dt2-footer-menu-item {
|
||||
padding : 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-base-100); /* Add background color */
|
||||
}
|
||||
|
||||
.dt2-footer-menu-item:hover {
|
||||
background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
|
||||
cursor : pointer
|
||||
}
|
||||
|
||||
.dt2-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -212,7 +237,7 @@ input:focus {
|
||||
.dt2-resize-handle::after {
|
||||
content: ''; /* This is required */
|
||||
position: absolute; /* Position as needed */
|
||||
z-index: 1;
|
||||
z-index: var(--datagrid-resize-zindex);
|
||||
display: block; /* Makes it a block element */
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
function bindDatagrid(datagridId, allowColumnsReordering) {
|
||||
bindTooltipsWithDelegation(datagridId);
|
||||
bindScrollbars(datagridId);
|
||||
makeResizable(datagridId)
|
||||
}
|
||||
@@ -26,16 +25,16 @@ function bindScrollbars(datagridId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scrollingTimeout;
|
||||
|
||||
const computeScrollbarVisibility = () => {
|
||||
// Determine if the content is clipped
|
||||
const isVerticalRequired = body.scrollHeight > body.clientHeight;
|
||||
const isHorizontalRequired = table.scrollWidth > table.clientWidth;
|
||||
|
||||
// Show or hide the scrollbar wrappers
|
||||
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
|
||||
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
|
||||
requestAnimationFrame(() => {
|
||||
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
|
||||
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
const computeScrollbarSize = () => {
|
||||
@@ -44,11 +43,9 @@ function bindScrollbars(datagridId) {
|
||||
const totalHeight = body.scrollHeight;
|
||||
const wrapperHeight = verticalWrapper.offsetHeight;
|
||||
|
||||
let scrollbarHeight = 0;
|
||||
if (totalHeight > 0) {
|
||||
const scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
|
||||
verticalScrollbar.style.height = `${scrollbarHeight}px`;
|
||||
} else {
|
||||
verticalScrollbar.style.height = `0px`;
|
||||
scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
|
||||
}
|
||||
|
||||
// Horizontal scrollbar width
|
||||
@@ -56,15 +53,18 @@ function bindScrollbars(datagridId) {
|
||||
const totalWidth = table.scrollWidth;
|
||||
const wrapperWidth = horizontalWrapper.offsetWidth;
|
||||
|
||||
let scrollbarWidth = 0;
|
||||
if (totalWidth > 0) {
|
||||
const scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
|
||||
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
horizontalScrollbar.style.width = `0px`;
|
||||
scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
verticalScrollbar.style.height = `${scrollbarHeight}px`;
|
||||
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const updateVerticalScrollbarPosition = () => {
|
||||
const updateVerticalScrollbarForMouseWheel = () => {
|
||||
const maxScrollTop = body.scrollHeight - body.clientHeight;
|
||||
const wrapperHeight = verticalWrapper.offsetHeight;
|
||||
|
||||
@@ -75,11 +75,16 @@ function bindScrollbars(datagridId) {
|
||||
};
|
||||
|
||||
const addDragEvent = (scrollbar, updateFunction) => {
|
||||
if (scrollbar.dataset.dragEventsBound === "true") {
|
||||
return; // Events are already bound to this scrollbar, so skip binding again
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let startY = 0;
|
||||
let startX = 0;
|
||||
|
||||
scrollbar.addEventListener("mousedown", (e) => {
|
||||
disableTooltip();
|
||||
isDragging = true;
|
||||
startY = e.clientY;
|
||||
startX = e.clientX;
|
||||
@@ -104,20 +109,23 @@ function bindScrollbars(datagridId) {
|
||||
isDragging = false;
|
||||
document.body.style.userSelect = ""; // Re-enable text selection
|
||||
scrollbar.classList.remove("dt2-dragging");
|
||||
enableTooltip();
|
||||
});
|
||||
|
||||
// Set a flag to indicate events are bound
|
||||
scrollbar.dataset.dragEventsBound = "true";
|
||||
};
|
||||
|
||||
const updateVerticalScrollbar = (deltaX, deltaY) => {
|
||||
const wrapperHeight = verticalWrapper.offsetHeight;
|
||||
const scrollbarHeight = verticalScrollbar.offsetHeight;
|
||||
const maxScrollTop = body.scrollHeight - body.clientHeight;
|
||||
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
|
||||
|
||||
let newTop = parseFloat(verticalScrollbar.style.top || "0") + deltaY;
|
||||
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
|
||||
|
||||
verticalScrollbar.style.top = `${newTop}px`;
|
||||
|
||||
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
|
||||
body.scrollTop = newTop * scrollRatio;
|
||||
};
|
||||
|
||||
@@ -125,13 +133,12 @@ function bindScrollbars(datagridId) {
|
||||
const wrapperWidth = horizontalWrapper.offsetWidth;
|
||||
const scrollbarWidth = horizontalScrollbar.offsetWidth;
|
||||
const maxScrollLeft = table.scrollWidth - table.clientWidth;
|
||||
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
|
||||
|
||||
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + deltaX;
|
||||
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
|
||||
|
||||
horizontalScrollbar.style.left = `${newLeft}px`;
|
||||
|
||||
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
|
||||
table.scrollLeft = newLeft * scrollRatio;
|
||||
};
|
||||
|
||||
@@ -144,7 +151,7 @@ function bindScrollbars(datagridId) {
|
||||
table.scrollLeft += deltaX; // Horizontal scrolling
|
||||
|
||||
// Update the vertical scrollbar position
|
||||
updateVerticalScrollbarPosition();
|
||||
updateVerticalScrollbarForMouseWheel();
|
||||
|
||||
|
||||
// Prevent default behavior to fully manage the scroll
|
||||
@@ -154,7 +161,7 @@ function bindScrollbars(datagridId) {
|
||||
addDragEvent(verticalScrollbar, updateVerticalScrollbar);
|
||||
addDragEvent(horizontalScrollbar, updateHorizontalScrollbar);
|
||||
|
||||
body.addEventListener("wheel", handleWheelScrolling);
|
||||
body.addEventListener("wheel", handleWheelScrolling, {passive: false});
|
||||
|
||||
// Initialize scrollbars
|
||||
computeScrollbarVisibility();
|
||||
@@ -164,7 +171,7 @@ function bindScrollbars(datagridId) {
|
||||
window.addEventListener("resize", () => {
|
||||
computeScrollbarVisibility();
|
||||
computeScrollbarSize();
|
||||
updateVerticalScrollbarPosition();
|
||||
updateVerticalScrollbarForMouseWheel();
|
||||
|
||||
});
|
||||
}
|
||||
@@ -229,7 +236,6 @@ function makeResizable(datagridId) {
|
||||
const {colIndex, startWidth, cells} = resizingState;
|
||||
|
||||
const finalWidth = cells[0].offsetWidth;
|
||||
console.debug(`Column ${colIndex} resized from ${startWidth}px to ${finalWidth}px`);
|
||||
|
||||
// Emit custom event (server communication can be tied here)
|
||||
const resizeEvent = new CustomEvent('columnResize', {
|
||||
@@ -263,6 +269,9 @@ function makeResizable(datagridId) {
|
||||
}
|
||||
|
||||
function bindColumnsSettings(datagridId) {
|
||||
/**
|
||||
* To change the order of the rows
|
||||
*/
|
||||
console.debug("bindColumnsSettings on element " + datagridId);
|
||||
const datagrid = document.querySelector(`#${datagridId}`);
|
||||
if (!datagrid) {
|
||||
@@ -354,23 +363,80 @@ function getColumnsDefinitions(columnsSettingsId) {
|
||||
}
|
||||
|
||||
function getCellId(event) {
|
||||
/*
|
||||
Find the id of the dt-body-cell
|
||||
*/
|
||||
|
||||
function findParentByName(element, name) {
|
||||
let parent = element;
|
||||
while (parent) {
|
||||
if (parent.getAttribute('name') === name) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return null; // Return null if no matching parent is found
|
||||
// Find element with data-col attribute
|
||||
const elementForColId = event.target.closest('[data-col]');
|
||||
if (!elementForColId) {
|
||||
return null; // Return null if we can't find a column element
|
||||
}
|
||||
|
||||
const colId = elementForColId.getAttribute('data-col');
|
||||
|
||||
// Check if element is in header
|
||||
const headerElement = event.target.closest('.dt2-header');
|
||||
if (headerElement) {
|
||||
return "header|" + colId + "|0";
|
||||
}
|
||||
|
||||
const parentElement = findParentByName(event.target, 'dt-body-cell')
|
||||
return parentElement ? parentElement.id : null;
|
||||
// Try to find row ID from data-row attribute
|
||||
const elementForRowId = event.target.closest('[data-row]');
|
||||
if (!elementForRowId) {
|
||||
return null; // Return null if we can't find a row element
|
||||
}
|
||||
|
||||
const rowId = elementForRowId.getAttribute('data-row');
|
||||
|
||||
// Get parent element to determine if in body or footer
|
||||
const elementForTablePart = elementForRowId.parentElement;
|
||||
|
||||
// Determine table part based on class
|
||||
let tablePart = null;
|
||||
if (elementForTablePart) {
|
||||
if (elementForTablePart.classList.contains("dt2-body")) {
|
||||
tablePart = "body";
|
||||
} else if (elementForTablePart.classList.contains("dt2-footer")) {
|
||||
tablePart = "footer";
|
||||
}
|
||||
}
|
||||
|
||||
// Return formatted cell identifier or null if essential data is missing
|
||||
if (colId && rowId && tablePart) {
|
||||
return tablePart + "|" + colId + "|" + rowId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCellBoundaries(event) {
|
||||
// Get the target element from the click event
|
||||
const target = event.target;
|
||||
|
||||
// Find the closest parent with the class 'dt2-cell'
|
||||
const cellElement = target.closest('.dt2-cell');
|
||||
|
||||
if (!cellElement) {
|
||||
console.warn('No parent element with class "dt2-cell" found.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the nearest ancestor with relative/absolute positioning
|
||||
const positionedAncestor = cellElement.offsetParent;
|
||||
|
||||
// Get the bounding rectangle of the cell
|
||||
const cellRect = cellElement.getBoundingClientRect();
|
||||
|
||||
// Determine the ancestor's position for relative adjustment
|
||||
const ancestorRect = positionedAncestor.getBoundingClientRect()
|
||||
|
||||
// Calculate x and y positions relative to the positioned ancestor
|
||||
const x = cellRect.left - ancestorRect.left; // Relative to the ancestor
|
||||
const y = cellRect.top - ancestorRect.top; // Relative to the ancestor
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
height: cellRect.height,
|
||||
width: cellRect.width,
|
||||
};
|
||||
}
|
||||
|
||||
function getClickModifier(event) {
|
||||
@@ -381,15 +447,49 @@ function getClickModifier(event) {
|
||||
const isAltGr = event.ctrlKey && event.altKey && !event.shiftKey && event.code === "AltRight";
|
||||
|
||||
if (!isAltGr) {
|
||||
if (event.altKey) { res += "alt-" }
|
||||
if (event.ctrlKey) { res += "ctrl-" }
|
||||
if (event.altKey) {
|
||||
res += "alt-"
|
||||
}
|
||||
if (event.ctrlKey) {
|
||||
res += "ctrl-"
|
||||
}
|
||||
} else {
|
||||
res += "altgr-"; // Special case for AltGr
|
||||
}
|
||||
|
||||
if (event.metaKey) { res += "meta-" }
|
||||
if (event.shiftKey) { res += "shift-" }
|
||||
if (event.metaKey) {
|
||||
res += "meta-"
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
res += "shift-"
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateOnClickRequest(datagridId, event) {
|
||||
if (event.target.id !== datagridId) { // only try to cancel event sent by the datagrid
|
||||
return;
|
||||
}
|
||||
|
||||
const triggeringElt = event.detail.requestConfig.triggeringEvent.target
|
||||
const sidebar = triggeringElt.closest('.dt2-sidebar');
|
||||
if (sidebar) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onAfterSettle(datagridId, event) {
|
||||
const datagrid = document.getElementById(datagridId);
|
||||
if (!datagrid) {
|
||||
console.error(`Datagrid with ID "${datagridId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only rebind if the table is resend
|
||||
const response = event.detail.xhr.responseText;
|
||||
if (response.includes("hx-on::before-settle")) {
|
||||
bindDatagrid(datagridId)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import copy
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
from typing import Literal, Any
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.components import *
|
||||
@@ -15,47 +15,94 @@ from components.datagrid_new.components.FileUpload import FileUpload
|
||||
from components.datagrid_new.components.FilterAll import FilterAll
|
||||
from components.datagrid_new.components.Views import Views
|
||||
from components.datagrid_new.components.commands import DataGridCommandManager
|
||||
from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ColumnType, FILTER_INPUT_CID, \
|
||||
ViewType
|
||||
from components.datagrid_new.settings import DataGridDatabaseManager, DataGridRowState, DataGridColumnState, \
|
||||
from components.datagrid_new.constants import *
|
||||
from components.datagrid_new.db_management import DataGridDbManager
|
||||
from components.datagrid_new.settings import DataGridRowState, DataGridColumnState, \
|
||||
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
|
||||
from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
|
||||
from components_helpers import mk_icon, mk_ellipsis
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.utils import get_unique_id, make_column_id
|
||||
from core.utils import get_unique_id, make_safe_id
|
||||
|
||||
logger = logging.getLogger("DataGrid")
|
||||
|
||||
|
||||
class DataGrid(BaseComponent):
|
||||
def __init__(self, session, _id: str = None, key: str = None, settings_manager=None):
|
||||
"""
|
||||
DataGrid component that provides a rich, interactive table with various features
|
||||
like filtering, column management, and view management.
|
||||
"""
|
||||
|
||||
def __init__(self, session, _id: str = None, key: Any = None, settings_manager=None, boundaries=None):
|
||||
"""
|
||||
Initialize the DataGrid component.
|
||||
|
||||
Args:
|
||||
session: The current session
|
||||
_id: Component ID
|
||||
key: Optional key for persistence
|
||||
settings_manager: Optional settings manager
|
||||
"""
|
||||
super().__init__(session, _id)
|
||||
|
||||
self.commands = DataGridCommandManager(self)
|
||||
|
||||
self._key = key
|
||||
self._settings_manager = settings_manager
|
||||
self._db = DataGridDatabaseManager(session, settings_manager, key)
|
||||
self._db = DataGridDbManager(session, settings_manager, key)
|
||||
|
||||
# Load state and data
|
||||
self._state: DataGridState = self._db.load_state()
|
||||
self._settings: DataGridSettings = self._db.load_settings()
|
||||
self._df: DataFrame | None = self._db.load_dataframe()
|
||||
|
||||
# update boundaries if possible
|
||||
self.set_boundaries(boundaries)
|
||||
|
||||
# Create child components
|
||||
self._file_upload = self._create_component(FileUpload, f"fu_{self._id}")
|
||||
self._filter_all = self._create_component(FilterAll, f"fa_{self._id}")
|
||||
self._columns_settings = self._create_component(ColumnsSettings, f"scol_{self._id}")
|
||||
self._views = self._create_component(Views, f"v_{self._id}")
|
||||
|
||||
# init
|
||||
# Initial setup
|
||||
self.close_sidebar()
|
||||
|
||||
# First time updates
|
||||
if len(self._state.footers) == 0:
|
||||
self._state.footers.append(DataGridFooterConf())
|
||||
|
||||
# ----------------------
|
||||
# Initialization Methods
|
||||
# ----------------------
|
||||
|
||||
def init_from_excel(self):
|
||||
df = pd.read_excel(BytesIO(self._file_upload.file_content),
|
||||
sheet_name=self._file_upload.selected_sheet_name)
|
||||
"""
|
||||
Initialize the DataGrid from an Excel file uploaded by the user.
|
||||
|
||||
Returns:
|
||||
self: For method chaining
|
||||
"""
|
||||
df = pd.read_excel(
|
||||
BytesIO(self._file_upload.file_content),
|
||||
sheet_name=self._file_upload.selected_sheet_name
|
||||
)
|
||||
|
||||
self._settings.file_name = self._file_upload.file_name
|
||||
self._settings.selected_sheet_name = self._file_upload.selected_sheet_name
|
||||
self._db.save_settings(self._settings)
|
||||
return self.init_from_dataframe(df)
|
||||
|
||||
return self.init_from_dataframe(df, save_state=True)
|
||||
|
||||
def init_from_dataframe(self, df: DataFrame):
|
||||
def init_from_dataframe(self, df: DataFrame, save_state=False):
|
||||
"""
|
||||
Initialize the DataGrid from a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
df: Source DataFrame
|
||||
save_state: Whether to save state to database
|
||||
|
||||
Returns:
|
||||
self: For method chaining
|
||||
"""
|
||||
|
||||
def _get_column_type(dtype):
|
||||
if pd.api.types.is_integer_dtype(dtype):
|
||||
return ColumnType.Number
|
||||
@@ -69,14 +116,17 @@ class DataGrid(BaseComponent):
|
||||
return ColumnType.Text # Default to Text if no match
|
||||
|
||||
self._df = df.copy()
|
||||
self._df.columns = self._df.columns.map(make_column_id) # make sure column names are trimmed
|
||||
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
|
||||
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
|
||||
self._state.columns = [DataGridColumnState(make_column_id(col_id),
|
||||
self._state.columns = [DataGridColumnState(make_safe_id(col_id),
|
||||
col_index,
|
||||
col_id,
|
||||
_get_column_type(self._df[make_column_id(col_id)].dtype))
|
||||
_get_column_type(self._df[make_safe_id(col_id)].dtype))
|
||||
for col_index, col_id in enumerate(df.columns)]
|
||||
self._db.save_all(None, self._state, self._df)
|
||||
|
||||
if save_state:
|
||||
self._db.save_all(None, self._state, self._df)
|
||||
|
||||
return self
|
||||
|
||||
def update_columns_state(self, updates: list[dict] | None = None, mode: Literal["delta", "replace"] = "delta"):
|
||||
@@ -131,7 +181,7 @@ class DataGrid(BaseComponent):
|
||||
new_column = False
|
||||
|
||||
if updates is None:
|
||||
return self.mk_table()
|
||||
return self.mk_table_container()
|
||||
|
||||
if mode == "delta":
|
||||
for update in updates:
|
||||
@@ -156,7 +206,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
self._db.save_all(self._settings, self._state, self._df if new_column else None)
|
||||
|
||||
return self.mk_table(), self.close_sidebar(), self._views.render_select_view(oob=True)
|
||||
return self.mk_table_container(), self.close_sidebar(), self._views.render_select_view(oob=True)
|
||||
|
||||
def add_view(self, view_name, columns: list[DataGridColumnState]):
|
||||
if view_name in [v.name for v in self._settings.views]:
|
||||
@@ -168,7 +218,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
self._db.save_all(settings=self._settings, state=self._state)
|
||||
|
||||
return self.mk_table()
|
||||
return self.mk_table_container()
|
||||
|
||||
def update_view(self, view_name, columns: list[DataGridColumnState]):
|
||||
view = self.get_view(view_name)
|
||||
@@ -179,7 +229,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
self._db.save_settings(self._settings)
|
||||
|
||||
return self.mk_table()
|
||||
return self.mk_table_container()
|
||||
|
||||
def change_view(self, view_name):
|
||||
|
||||
@@ -189,7 +239,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
self._state.selected_view = view_name
|
||||
|
||||
return self.mk_table()
|
||||
return self.mk_table_container()
|
||||
|
||||
def filter(self, column_id: str, filtering_values: str | list[str]):
|
||||
"""
|
||||
@@ -204,7 +254,7 @@ class DataGrid(BaseComponent):
|
||||
else:
|
||||
self._state.filtered[column_id] = filtering_values
|
||||
|
||||
return self.mk_table()
|
||||
return self.mk_table_container()
|
||||
|
||||
def finalize_interaction(self, new_pos=None, new_input_element=None):
|
||||
res = []
|
||||
@@ -214,6 +264,9 @@ class DataGrid(BaseComponent):
|
||||
self._state.sidebar_visible = False
|
||||
res.append(self.mk_sidebar(None, self._state.sidebar_visible, oob=True))
|
||||
|
||||
# reset footer menu
|
||||
res.append(self.mk_footer_menu(None, oob=True))
|
||||
|
||||
# manage the selection
|
||||
select_manager = self.mk_selection_manager(new_pos)
|
||||
res.append(select_manager)
|
||||
@@ -258,10 +311,33 @@ class DataGrid(BaseComponent):
|
||||
new_pos = self.navigate(None) # create the cursor if it was the first time
|
||||
return self.finalize_interaction(new_pos)
|
||||
|
||||
def manage_click(self, col_index, row_index, modifier=''):
|
||||
def manage_click(self, cell_id, modifier, boundaries):
|
||||
try:
|
||||
source, col_id, row_index = cell_id.split("|")
|
||||
if source == "footer":
|
||||
return self.show_footer_menu(col_id, int(row_index), boundaries)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
new_pos = self.escape()
|
||||
return self.finalize_interaction(new_pos)
|
||||
|
||||
def manage_state_changed(self, state, args):
|
||||
if state == DATAGRID_STATE_FOOTER:
|
||||
col_id, row_index, agg_value = args.split("|")
|
||||
footer_conf = self._state.footers[int(row_index)]
|
||||
footer_conf.conf[col_id] = agg_value
|
||||
self._db.save_state(self._state)
|
||||
col_def = self._get_col_def(col_id)
|
||||
footer_conf = self._state.footers[int(row_index)]
|
||||
return (self.mk_table_footer_cell(col_def, int(row_index), footer_conf, True),
|
||||
self.mk_footer_menu(None, oob=True))
|
||||
|
||||
return self.mk_table()
|
||||
|
||||
def show_footer_menu(self, col_id: str, row_index: int, boundaries: dict):
|
||||
return self.mk_footer_menu(col_id, row_index, boundaries)
|
||||
|
||||
def get_state(self) -> DataGridState:
|
||||
return self._state
|
||||
|
||||
@@ -271,14 +347,30 @@ class DataGrid(BaseComponent):
|
||||
def get_table_id(self):
|
||||
return f"t_{self._id}"
|
||||
|
||||
def get_view(self, view_name: str = None) -> DatagridView | None:
|
||||
def get_view(self, view_name: str | None = None) -> DatagridView | None:
|
||||
if view_name is None:
|
||||
view_name = self._state.selected_view
|
||||
|
||||
if view_name is None: # if self._state.selected_view is None
|
||||
return None
|
||||
|
||||
try:
|
||||
return next(view for view in self._settings.views if view.name == view_name)
|
||||
except StopIteration:
|
||||
return None
|
||||
raise ValueError(f"View '{view_name}' does not exist.")
|
||||
|
||||
def set_boundaries(self, boundaries):
|
||||
if boundaries is not None:
|
||||
self._state.boundaries = {CONTAINER_WIDTH: boundaries['width'],
|
||||
CONTAINER_HEIGHT: boundaries['height']}
|
||||
|
||||
def mk_table_container(self):
|
||||
return Div(
|
||||
self.mk_scrollbars(),
|
||||
self.mk_table(),
|
||||
cls="dt2-container",
|
||||
id=f"tc_{self._id}"
|
||||
)
|
||||
|
||||
def mk_scrollbars(self):
|
||||
return Div(
|
||||
@@ -290,7 +382,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
def mk_table(self, oob=False):
|
||||
htmx_extra_params = {
|
||||
"hx-on::after-settle": f"bindDatagrid('{self._id}', true);",
|
||||
"hx-on::before-settle": f"onAfterSettle('{self._id}', event);",
|
||||
# "hx-on::before-request": "onCellEdition(event);",
|
||||
}
|
||||
|
||||
@@ -331,18 +423,15 @@ class DataGrid(BaseComponent):
|
||||
hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
|
||||
hx_target=f"#tsm_{self._id}",
|
||||
hx_swap="outerHTML"),
|
||||
name="dt2-kbm",
|
||||
),
|
||||
|
||||
if self._df is None:
|
||||
return Div(id=f"t_{self._id}")
|
||||
|
||||
return Div(
|
||||
self.mk_selection_manager(),
|
||||
Div(Label(mk_icon(icon_move), cls="flex gap-2"), id=f"tdd_{self._id}", cls="dt2-drag-drop"),
|
||||
Div(id=f"tcdd_{self._id}"),
|
||||
self.mk_footer_menu(None),
|
||||
_mk_keyboard_management(),
|
||||
Div(
|
||||
self.mk_scrollbars(),
|
||||
self.mk_table_header(),
|
||||
self.mk_table_body(),
|
||||
self.mk_table_footer(),
|
||||
@@ -386,6 +475,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
def mk_table_body(self):
|
||||
df = self._get_filtered_df()
|
||||
max_height = self._compute_body_max_height()
|
||||
|
||||
return Div(
|
||||
*[Div(
|
||||
@@ -395,34 +485,18 @@ class DataGrid(BaseComponent):
|
||||
id=f"tr_{self._id}-{row_index}",
|
||||
) for row_index in df.index],
|
||||
cls="dt2-body",
|
||||
style=f"max-height:{max_height}px;",
|
||||
id=f"tb_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table_footer(self):
|
||||
def _mk_footer(footer: DataGridFooterConf, col_def: DataGridColumnState):
|
||||
if not col_def.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
if col_def.col_id in footer.conf:
|
||||
value = "Found !"
|
||||
else:
|
||||
value = "very long Footer"
|
||||
|
||||
return Div(mk_ellipsis(value),
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls="dt2-cell ",
|
||||
)
|
||||
|
||||
return Div(
|
||||
*[Div(
|
||||
*[_mk_footer(footer, col_def) for col_def in self._state.columns],
|
||||
*[self.mk_table_footer_cell(col_def, row_index, footer) for col_def in self._state.columns],
|
||||
id=f"tf_{self._id}",
|
||||
data_row=f"{row_index}",
|
||||
cls="dt2-row dt2-row-footer",
|
||||
) for footer in self._state.footers or [DataGridFooterConf()]],
|
||||
) for row_index, footer in enumerate(self._state.footers)],
|
||||
cls="dt2-footer",
|
||||
id=f"tf_{self._id}"
|
||||
)
|
||||
@@ -434,17 +508,14 @@ class DataGrid(BaseComponent):
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
content, extra_cls = self.mk_body_cell_content(col_pos, row_index, col_def)
|
||||
cls_to_use = "dt2-cell" + (f" {extra_cls}" if extra_cls else "")
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def)
|
||||
|
||||
return Div(content,
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls=cls_to_use)
|
||||
cls="dt2-cell")
|
||||
|
||||
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
|
||||
cls = ""
|
||||
content = ""
|
||||
|
||||
def mk_bool(value):
|
||||
return Div(mk_icon(icon_checked if value else icon_unchecked, can_select=False),
|
||||
@@ -473,17 +544,80 @@ class DataGrid(BaseComponent):
|
||||
res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
|
||||
return tuple(res)
|
||||
|
||||
if col_def.visible:
|
||||
column_type = col_def.type
|
||||
|
||||
if column_type == ColumnType.Bool:
|
||||
content = mk_bool(self._df.iloc[row_index, col_def.col_index])
|
||||
elif column_type == ColumnType.Number:
|
||||
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
|
||||
else:
|
||||
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
|
||||
column_type = col_def.type
|
||||
|
||||
return content, cls
|
||||
if column_type == ColumnType.Bool:
|
||||
content = mk_bool(self._df.iloc[row_index, col_def.col_index])
|
||||
elif column_type == ColumnType.Number:
|
||||
content = mk_number(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
|
||||
elif column_type == ColumnType.RowIndex:
|
||||
content = mk_number(row_index)
|
||||
else:
|
||||
content = mk_text(process_cell_content(self._df.iloc[row_index, col_def.col_index]))
|
||||
|
||||
return content
|
||||
|
||||
def mk_table_footer_cell(self, col_def, row_index: int, footer_conf, oob=False):
|
||||
"""
|
||||
Generates a footer cell for a data table based on the provided column definition,
|
||||
row index, footer configuration, and optional out-of-bound setting. This method
|
||||
applies appropriate aggregation functions, determines visibility, and structures
|
||||
the cell's elements accordingly.
|
||||
|
||||
:param col_def: Details of the column state, including its usability, visibility,
|
||||
and column ID, which are necessary to determine how the footer
|
||||
cell should be created.
|
||||
:type col_def: DataGridColumnState
|
||||
:param row_index: The specific index of the footer row where this cell will be
|
||||
added. This parameter is used to uniquely identify the cell
|
||||
within the footer.
|
||||
:type row_index: int
|
||||
:param footer_conf: Configuration for the footer that contains mapping of column
|
||||
IDs to their corresponding aggregation functions. This is
|
||||
critical for calculating aggregated values for the cell content.
|
||||
:type footer_conf: DataGridFooterConf
|
||||
:param oob: A boolean flag indicating whether the configuration involves any
|
||||
out-of-bound parameters that must be handled specifically. This
|
||||
parameter is optional and defaults to False.
|
||||
:type oob: bool
|
||||
:return: Returns an instance of `Div`, containing the visually structured footer
|
||||
cell content, including the calculated aggregation if applicable. If
|
||||
the column is not usable, it returns None. For non-visible columns, it
|
||||
returns a hidden cell `Div`. The aggregation value is displayed for valid
|
||||
aggregations. If none is applicable or the configuration is invalid,
|
||||
appropriate default content or styling is applied.
|
||||
:rtype: Div | None
|
||||
"""
|
||||
if not col_def.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
if col_def.col_id in footer_conf.conf:
|
||||
agg_function = footer_conf.conf[col_def.col_id]
|
||||
if agg_function == FooterAggregation.Sum.value:
|
||||
value = self._df[col_def.col_id].sum()
|
||||
elif agg_function == FooterAggregation.Min.value:
|
||||
value = self._df[col_def.col_id].min()
|
||||
elif agg_function == FooterAggregation.Max.value:
|
||||
value = self._df[col_def.col_id].max()
|
||||
elif agg_function == FooterAggregation.Mean.value:
|
||||
value = self._df[col_def.col_id].mean()
|
||||
elif agg_function == FooterAggregation.Count.value:
|
||||
value = self._df[col_def.col_id].count()
|
||||
else:
|
||||
value = "** Invalid aggregation function **"
|
||||
else:
|
||||
value = None
|
||||
|
||||
return Div(mk_ellipsis(value, cls="dt2-cell-content-number"),
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls="dt2-cell dt2-footer-cell",
|
||||
id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
|
||||
hx_swap_oob='true' if oob else None,
|
||||
)
|
||||
|
||||
def mk_menu(self, oob=False):
|
||||
return Div(
|
||||
@@ -550,6 +684,38 @@ class DataGrid(BaseComponent):
|
||||
|
||||
return select_manager
|
||||
|
||||
def mk_footer_menu(self, col_id: str | None, row_index: int = None, cell_boundaries: dict = None, oob=False):
|
||||
def _compute_footer_menu_height(_cell_boundaries: dict):
|
||||
y = _cell_boundaries['y'] # position of the footer cell that was clicked
|
||||
footer_height = _cell_boundaries['height'] # position of the footer cell that was clicked
|
||||
table_max_height = self._state.boundaries[CONTAINER_HEIGHT]
|
||||
menu_bottom = y + footer_height + len(FooterAggregation) * 21 + 16
|
||||
return y + footer_height \
|
||||
if menu_bottom < table_max_height \
|
||||
else y - footer_height - len(FooterAggregation) * 21 + 16
|
||||
|
||||
if col_id is None:
|
||||
return Div(cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm ",
|
||||
id=f"tcm_{self._id}",
|
||||
hx_swap_oob='true' if oob else None,
|
||||
)
|
||||
|
||||
if col_id:
|
||||
assert cell_boundaries is not None, "boundaries must be provided if col_id is provided"
|
||||
|
||||
menu_top = _compute_footer_menu_height(cell_boundaries)
|
||||
|
||||
return Div(
|
||||
*[Div(
|
||||
mk_ellipsis(agg.value),
|
||||
**self.commands.update_footer_aggregation(col_id, row_index, agg.value, cls="dt2-footer-menu-item"),
|
||||
) for agg in FooterAggregation],
|
||||
cls="dt2-footer-menu menu menu-sm rounded-box shadow-sm show",
|
||||
style=f"left:{cell_boundaries['x'] + 10}px;top:{menu_top}px;",
|
||||
id=f"tcm_{self._id}",
|
||||
hx_swap_oob='true' if oob else None,
|
||||
)
|
||||
|
||||
def toggle_sidebar(self, content):
|
||||
logger.debug(f"toggle sidebar {self._id}. Previous state: {self._state.sidebar_visible}")
|
||||
self._state.sidebar_visible = not self._state.sidebar_visible
|
||||
@@ -569,7 +735,7 @@ class DataGrid(BaseComponent):
|
||||
|
||||
def _get_filtered_df(self):
|
||||
if self._df is None:
|
||||
return None
|
||||
return DataFrame()
|
||||
|
||||
df = self._df.copy()
|
||||
df = self._apply_sort(df) # need to keep the real type to sort
|
||||
@@ -577,6 +743,9 @@ class DataGrid(BaseComponent):
|
||||
|
||||
return df
|
||||
|
||||
def _get_col_def(self, col_id: str):
|
||||
return next((col_def for col_def in self._state.columns if col_def.col_id == col_id), None)
|
||||
|
||||
def _apply_sort(self, df):
|
||||
if df is None:
|
||||
return None
|
||||
@@ -613,21 +782,37 @@ class DataGrid(BaseComponent):
|
||||
component_type,
|
||||
owner=self)
|
||||
|
||||
def _compute_body_max_height(self):
|
||||
filter_height = 40
|
||||
header_height = 24
|
||||
footer_height = 24
|
||||
h_scrollbar_height = 25
|
||||
to_subtract = filter_height + header_height + footer_height + h_scrollbar_height + 5
|
||||
|
||||
max_height = self._state.boundaries.get(CONTAINER_HEIGHT, DATAGRID_MAX_HEIGHT) \
|
||||
if self._state.boundaries \
|
||||
else DATAGRID_MAX_HEIGHT
|
||||
|
||||
max_height -= to_subtract
|
||||
|
||||
return max_height
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
mk_tooltip_container(self._id),
|
||||
Div(Div(self._filter_all, self._views, cls="flex"),
|
||||
self.mk_menu(), cls="flex justify-between"),
|
||||
|
||||
Div(
|
||||
self.mk_table(),
|
||||
self.mk_sidebar(None, self._state.sidebar_visible),
|
||||
cls="dt2-main",
|
||||
Div(Div(self._filter_all, self._views, cls="flex"),
|
||||
self.mk_menu(), cls="flex justify-between"),
|
||||
|
||||
Div(
|
||||
self.mk_table_container(),
|
||||
self.mk_sidebar(None, self._state.sidebar_visible),
|
||||
cls="dt2-main",
|
||||
),
|
||||
|
||||
Script(f"bindDatagrid('{self._id}', false);"),
|
||||
**self.commands.on_click()
|
||||
),
|
||||
|
||||
Script(f"bindDatagrid('{self._id}', false);"),
|
||||
id=f"{self._id}",
|
||||
**self.commands.on_click()
|
||||
id=f"{self._id}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -4,6 +4,7 @@ from fasthtml.components import *
|
||||
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
|
||||
from components.datagrid_new.components.commands import FilterAllCommands
|
||||
from components.datagrid_new.constants import Routes, ROUTE_ROOT, FILTER_INPUT_CID, DATAGRID_INSTANCE_ID
|
||||
from core.utils import get_unique_id
|
||||
|
||||
@@ -21,6 +22,7 @@ class FilterAll(BaseComponent, ):
|
||||
:param datagrid:
|
||||
"""
|
||||
super().__init__(session, _id)
|
||||
self.commands = FilterAllCommands(self)
|
||||
self._owner = owner
|
||||
logger.debug(f"FilterAll component created with id: {self._id}")
|
||||
|
||||
@@ -46,11 +48,13 @@ class FilterAll(BaseComponent, ):
|
||||
Input(name='f',
|
||||
placeholder="Filter...",
|
||||
value=value,
|
||||
#**self.commands.filter_all(),
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.Filter}",
|
||||
hx_trigger="keyup changed throttle:300ms",
|
||||
hx_trigger="keyup",
|
||||
hx_target=f"#t_{self._owner.get_id()}",
|
||||
hx_swap="outerHTML",
|
||||
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
|
||||
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
|
||||
),
|
||||
cls="input input-sm flex gap-2"
|
||||
),
|
||||
id=f"fi_{self._id}", # fa stands for 'filter all'
|
||||
@@ -59,12 +63,17 @@ class FilterAll(BaseComponent, ):
|
||||
|
||||
def _mk_reset_button(self):
|
||||
return Div(icon_dismiss_regular,
|
||||
cls="icon-24 my-auto icon-btn ml-2",
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
|
||||
hx_trigger="click",
|
||||
hx_target=f"#t_{self._owner.get_id()}",
|
||||
hx_swap="outerHTML",
|
||||
hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'),
|
||||
#cls="icon-24 my-auto icon-btn ml-2",
|
||||
**self.commands.reset_filter_all(cls="icon-24 my-auto icon-btn ml-2"),
|
||||
# hx_post=f"{ROUTE_ROOT}{Routes.ResetFilter}",
|
||||
# hx_trigger="click",
|
||||
# hx_target=f"#t_{self._owner.get_id()}",
|
||||
# hx_swap="outerHTML",
|
||||
# hx_vals=f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}'
|
||||
),
|
||||
|
||||
def get_datagrid(self):
|
||||
return self._owner
|
||||
|
||||
@staticmethod
|
||||
def create_component_id(session, prefix=None, suffix=None):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import json
|
||||
|
||||
from components.datagrid_new.constants import ROUTE_ROOT, Routes
|
||||
from components.BaseCommandManager import BaseCommandManager
|
||||
from components.datagrid_new.constants import ROUTE_ROOT, Routes, DATAGRID_STATE_FOOTER, FILTER_INPUT_CID
|
||||
|
||||
|
||||
class DataGridCommandManager:
|
||||
def __init__(self, datagrid):
|
||||
self.datagrid = datagrid
|
||||
self._id = self.datagrid.get_id()
|
||||
class DataGridCommandManager(BaseCommandManager):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def cancel(self):
|
||||
return {
|
||||
@@ -53,7 +53,7 @@ class DataGridCommandManager:
|
||||
def update_columns_settings(self, component):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
|
||||
"hx-target": f"#{self.datagrid.get_table_id()}", # table
|
||||
"hx-target": f"#{self._owner.get_table_id()}", # table
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'js:{{"_id": "{self._id}", "updates": getColumnsDefinitions("{component.get_id()}")}}',
|
||||
}
|
||||
@@ -64,21 +64,37 @@ class DataGridCommandManager:
|
||||
def show_columns(self, col_defs: list, cls=""):
|
||||
return self._get_hide_show_columns_attrs("Show", col_defs, "true", cls=cls)
|
||||
|
||||
def reset_filters(self, cls=""):
|
||||
return {"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
|
||||
"hx_vals": f'{{"g_id": "{self._id}"}}',
|
||||
"hx_target": f"#t_{self._id}",
|
||||
"hx_swap": "outerHTML",
|
||||
"data_tooltip": "Reset all filters",
|
||||
"cls": self.merge_class(cls, "dt-tooltip")}
|
||||
def show_footer_menu(self, col_id, cls=""):
|
||||
return {
|
||||
"hx_post": f"{ROUTE_ROOT}{Routes.ShowFooterMenu}",
|
||||
"hx_target": f"#tcm_{self._id}",
|
||||
"hx_swap": "outerHTML",
|
||||
"hx-trigger": "click consume",
|
||||
"hx_vals": f'js:{{"_id": "{self._id}", "col_id": "{col_id}", "boundaries": getCellBoundaries(event)}}',
|
||||
"data_tooltip": "Change aggregation",
|
||||
"cls": self.merge_class(cls, "mmt-tooltip")
|
||||
}
|
||||
|
||||
def update_footer_aggregation(self, col_id, row_index, agg_type, cls=""):
|
||||
args = f"{col_id}|{row_index}|{agg_type}"
|
||||
return {
|
||||
"hx_post": f"{ROUTE_ROOT}{Routes.UpdateState}",
|
||||
"hx_target": f"#t_{self._id}",
|
||||
"hx_swap": "none",
|
||||
"hx_vals": f'{{"_id": "{self._id}", "state": "{DATAGRID_STATE_FOOTER}", "args": "{args}"}}',
|
||||
"hx-trigger": "click consume",
|
||||
# "data_tooltip": f"Change footer aggregation to {agg_type}",
|
||||
"cls": self.merge_class(cls, "mmt-tooltip")
|
||||
}
|
||||
|
||||
def on_click(self):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
|
||||
"hx-target": f"#tsm_{self._id}",
|
||||
"hx-trigger"
|
||||
"hx-trigger" : "click",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event)}}',
|
||||
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event), boundaries: getCellBoundaries(event)}}',
|
||||
"hx-on::before-request": f'validateOnClickRequest("{self._id}", event)',
|
||||
}
|
||||
|
||||
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
|
||||
@@ -93,35 +109,60 @@ class DataGridCommandManager:
|
||||
"data_tooltip": tooltip_msg,
|
||||
"cls": self.merge_class(cls, "mmt-tooltip")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def merge(*items):
|
||||
"""
|
||||
Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
|
||||
If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
|
||||
If the key's value is not a string, an error is raised.
|
||||
#
|
||||
# @staticmethod
|
||||
# def merge(*items):
|
||||
# """
|
||||
# Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
|
||||
# If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
|
||||
# If the key's value is not a string, an error is raised.
|
||||
#
|
||||
# :param items: dictionaries to be merged. If all items are None, None is returned.
|
||||
# :return: A single dictionary containing the merged key-value pairs from all input dictionaries.
|
||||
# :raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
|
||||
# """
|
||||
# if all(item is None for item in items):
|
||||
# return None
|
||||
#
|
||||
# res = {}
|
||||
# for item in [item for item in items if item is not None]:
|
||||
#
|
||||
# for key, value in item.items():
|
||||
# if not key in res:
|
||||
# res[key] = value
|
||||
# else:
|
||||
# if isinstance(res[key], str):
|
||||
# res[key] += " " + value
|
||||
# else:
|
||||
# raise NotImplementedError("")
|
||||
#
|
||||
# return res
|
||||
#
|
||||
# @staticmethod
|
||||
# def merge_class(cls1, cls2):
|
||||
# return (cls1 + " " + cls2) if cls2 else cls1
|
||||
|
||||
:param items: dictionaries to be merged. If all items are None, None is returned.
|
||||
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
|
||||
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
|
||||
"""
|
||||
if all(item is None for item in items):
|
||||
return None
|
||||
|
||||
res = {}
|
||||
for item in [item for item in items if item is not None]:
|
||||
|
||||
for key, value in item.items():
|
||||
if not key in res:
|
||||
res[key] = value
|
||||
else:
|
||||
if isinstance(res[key], str):
|
||||
res[key] += " " + value
|
||||
else:
|
||||
raise NotImplementedError("")
|
||||
|
||||
return res
|
||||
|
||||
class FilterAllCommands(BaseCommandManager):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
@staticmethod
|
||||
def merge_class(cls1, cls2):
|
||||
return (cls1 + " " + cls2) if cls2 else cls1
|
||||
def filter_all(self):
|
||||
return {
|
||||
"hx_post": f"{ROUTE_ROOT}{Routes.Filter}",
|
||||
"hx_trigger": "keyup changed consume",
|
||||
"hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
|
||||
"hx_swap": "outerHTML",
|
||||
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
|
||||
}
|
||||
|
||||
def reset_filter_all(self, cls=""):
|
||||
return {
|
||||
"hx_post": f"{ROUTE_ROOT}{Routes.ResetFilter}",
|
||||
"hx_trigger": "click consume",
|
||||
"hx_target": f"#{self._owner.get_datagrid().get_table_id()}",
|
||||
"hx_swap": "outerHTML",
|
||||
"hx_vals": f'{{"_id": "{self._id}", "col_id":"{FILTER_INPUT_CID}"}}',
|
||||
"data_tooltip": "Reset filter",
|
||||
"cls": self.merge_class(cls, "mmt-tooltip"),
|
||||
}
|
||||
@@ -6,6 +6,18 @@ FILTER_INPUT_CID = "__filter_input__"
|
||||
DEFAULT_COLUMN_WIDTH = 100
|
||||
ADD_NEW_VIEW = "__add_new_view__"
|
||||
|
||||
DATAGRID_DB_ENTRY = "Datagrid"
|
||||
DATAGRID_DB_SETTINGS_ENTRY = "Settings"
|
||||
DATAGRID_DB_STATE_ENTRY = "State"
|
||||
DATAGRID_DB_DATAFRAME_ENTRY = "Dataframe"
|
||||
|
||||
DATAGRID_MAX_HEIGHT = 300
|
||||
CONTAINER_WIDTH = "container_width"
|
||||
CONTAINER_HEIGHT = "container_height"
|
||||
|
||||
DATAGRID_STATE_FOOTER = "footer"
|
||||
|
||||
|
||||
class Routes:
|
||||
Filter = "/filter" # request the filtering in the grid
|
||||
ResetFilter = "/reset_filter" #
|
||||
@@ -19,6 +31,8 @@ class Routes:
|
||||
ChangeView = "/change_view"
|
||||
AddView = "/add_view"
|
||||
UpdateView = "/update_view"
|
||||
ShowFooterMenu = "/show_footer_menu"
|
||||
UpdateState = "/update_state"
|
||||
|
||||
|
||||
class ColumnType(Enum):
|
||||
@@ -34,3 +48,15 @@ class ViewType(Enum):
|
||||
Table = "Table"
|
||||
Chart = "Chart"
|
||||
Form = "Form"
|
||||
|
||||
class FooterAggregation(Enum):
|
||||
Sum = "Sum"
|
||||
Mean = "Mean"
|
||||
Min = "Min"
|
||||
Max = "Max"
|
||||
Count = "Count"
|
||||
FilteredSum = "FilteredSum"
|
||||
FilteredMean = "FilteredMean"
|
||||
FilteredMin = "FilteredMin"
|
||||
FilteredMax = "FilteredMax"
|
||||
FilteredCount = "FilteredCount"
|
||||
111
src/components/datagrid_new/db_management.py
Normal file
111
src/components/datagrid_new/db_management.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from pandas import DataFrame
|
||||
|
||||
from components.datagrid_new.constants import DATAGRID_DB_ENTRY, DATAGRID_DB_SETTINGS_ENTRY, DATAGRID_DB_STATE_ENTRY, \
|
||||
DATAGRID_DB_DATAFRAME_ENTRY
|
||||
from components.datagrid_new.settings import DataGridSettings, DataGridState
|
||||
from core.settings_management import SettingsManager
|
||||
from core.utils import make_safe_id
|
||||
|
||||
|
||||
class DataframeWrapper:
|
||||
def __init__(self, df: DataFrame):
|
||||
self.df = df
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, DataframeWrapper):
|
||||
return self.df.equals(other.df)
|
||||
else:
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.df.to_json())
|
||||
|
||||
@staticmethod
|
||||
def use_refs():
|
||||
return {"df"}
|
||||
|
||||
|
||||
class DataGridDbManager:
|
||||
def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
|
||||
self._session = session
|
||||
self._settings_manager = settings_manager
|
||||
self._key = "#".join(make_safe_id(item) for item in key) if key else ""
|
||||
|
||||
# init the db if needed
|
||||
if self._settings_manager and not self._settings_manager.exists(self._session, self._get_db_entry()):
|
||||
self._settings_manager.save(self._session, self._get_db_entry(), {})
|
||||
|
||||
def _get_db_entry(self):
|
||||
return f"{DATAGRID_DB_ENTRY}_{self._key}"
|
||||
|
||||
def save_settings(self, settings: DataGridSettings):
|
||||
if self._settings_manager is None:
|
||||
return
|
||||
|
||||
self._settings_manager.put(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_SETTINGS_ENTRY,
|
||||
settings)
|
||||
|
||||
def save_state(self, state: DataGridState):
|
||||
if self._settings_manager is None:
|
||||
return
|
||||
|
||||
self._settings_manager.put(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_STATE_ENTRY,
|
||||
state)
|
||||
|
||||
def save_dataframe(self, df: DataFrame):
|
||||
if self._settings_manager is None:
|
||||
return
|
||||
|
||||
self._settings_manager.put(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_DATAFRAME_ENTRY,
|
||||
DataframeWrapper(df))
|
||||
|
||||
def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
|
||||
if self._settings_manager is None:
|
||||
return
|
||||
|
||||
items = {}
|
||||
if settings is not None:
|
||||
items[DATAGRID_DB_SETTINGS_ENTRY] = settings
|
||||
if state is not None:
|
||||
items[DATAGRID_DB_STATE_ENTRY] = state
|
||||
if df is not None:
|
||||
items[DATAGRID_DB_DATAFRAME_ENTRY] = DataframeWrapper(df)
|
||||
|
||||
self._settings_manager.put_many(self._session, self._get_db_entry(), items)
|
||||
|
||||
def load_settings(self):
|
||||
if self._settings_manager is None:
|
||||
return DataGridSettings()
|
||||
|
||||
return self._settings_manager.get(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_SETTINGS_ENTRY,
|
||||
default=DataGridSettings())
|
||||
|
||||
def load_state(self):
|
||||
if self._settings_manager is None:
|
||||
return DataGridState()
|
||||
|
||||
return self._settings_manager.get(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_STATE_ENTRY,
|
||||
default=DataGridState())
|
||||
|
||||
def load_dataframe(self):
|
||||
if self._settings_manager is None:
|
||||
return None
|
||||
|
||||
wrapper = self._settings_manager.get(self._session,
|
||||
self._get_db_entry(),
|
||||
DATAGRID_DB_DATAFRAME_ENTRY,
|
||||
default=None)
|
||||
if wrapper is None:
|
||||
return None
|
||||
|
||||
return wrapper.df
|
||||
@@ -1,15 +1,6 @@
|
||||
import dataclasses
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
import pandas as pd
|
||||
from pandas import DataFrame
|
||||
|
||||
from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType, ROUTE_ROOT, Routes
|
||||
from core.settings_management import SettingsManager, SettingsTransaction
|
||||
from core.utils import make_column_id
|
||||
|
||||
DATAGRID_SETTINGS_ENTRY = "DatagridSettings"
|
||||
from components.datagrid_new.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -48,7 +39,7 @@ class DatagridSelectionState:
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DataGridFooterConf:
|
||||
conf: dict[str, str] = dataclasses.field(default_factory=dict)
|
||||
conf: dict[str, str] = dataclasses.field(default_factory=dict) # first 'str' is the column id
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -64,65 +55,25 @@ class DataGridSettings:
|
||||
selected_sheet_name: str = None
|
||||
header_visible: bool = True
|
||||
views: list[DatagridView] = dataclasses.field(default_factory=list)
|
||||
|
||||
@staticmethod
|
||||
def use_refs():
|
||||
return {"views"}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DataGridState:
|
||||
sidebar_visible: bool = False
|
||||
selected_view: str = None
|
||||
columns: list[DataGridColumnState] = None
|
||||
rows: list[DataGridRowState] = None # only the rows that have a specific state
|
||||
columns: list[DataGridColumnState] = dataclasses.field(default_factory=list)
|
||||
rows: list[DataGridRowState] = dataclasses.field(default_factory=list) # only the rows that have a specific state
|
||||
footers: list[DataGridFooterConf] = dataclasses.field(default_factory=list)
|
||||
sorted: list = dataclasses.field(default_factory=list)
|
||||
filtered: dict = dataclasses.field(default_factory=dict)
|
||||
edition: DatagridEditionState = dataclasses.field(default_factory=DatagridEditionState)
|
||||
selection: DatagridSelectionState = dataclasses.field(default_factory=DatagridSelectionState)
|
||||
|
||||
|
||||
class DataGridDatabaseManager:
|
||||
def __init__(self, session: dict, settings_manager: SettingsManager, key: str):
|
||||
self._session = session
|
||||
self._settings_manager = settings_manager
|
||||
self._key = "#".join(make_column_id(item) for item in key)
|
||||
boundaries: dict = None # {height; width} that gives the maximum space possible
|
||||
|
||||
def save_settings(self, settings: DataGridSettings):
|
||||
self._settings_manager.put(self._session, self.get_settings_entry(), settings)
|
||||
|
||||
def save_state(self, state: DataGridState):
|
||||
self._settings_manager.put(self._session, self.get_state_entry(), state)
|
||||
|
||||
def save_dataframe(self, df: DataFrame):
|
||||
self._settings_manager.put(self._session, self.get_data_entry(), df.to_json())
|
||||
|
||||
def save_all(self, settings: DataGridSettings = None, state: DataGridState = None, df: DataFrame = None):
|
||||
with SettingsTransaction(self._session, self._settings_manager) as st:
|
||||
if settings is not None:
|
||||
st.put(self.get_settings_entry(), settings)
|
||||
if state is not None:
|
||||
st.put(self.get_state_entry(), state)
|
||||
if df is not None:
|
||||
st.put(self.get_data_entry(), df.to_json())
|
||||
|
||||
def load_settings(self):
|
||||
return self._settings_manager.get(self._session, self.get_settings_entry(), default=DataGridSettings())
|
||||
|
||||
def load_state(self):
|
||||
return self._settings_manager.get(self._session, self.get_state_entry(), default=DataGridState())
|
||||
|
||||
def load_dataframe(self):
|
||||
as_json = self._settings_manager.get(self._session, self.get_data_entry(), default=None)
|
||||
if as_json is None:
|
||||
return None
|
||||
|
||||
df = pd.read_json(StringIO(as_json))
|
||||
return df
|
||||
|
||||
def get_settings_entry(self):
|
||||
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_settings"
|
||||
|
||||
def get_state_entry(self):
|
||||
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_state"
|
||||
|
||||
def get_data_entry(self):
|
||||
return f"{DATAGRID_SETTINGS_ENTRY}_{self._key}_data"
|
||||
|
||||
@staticmethod
|
||||
def use_refs():
|
||||
return {"columns", "rows", "footers"}
|
||||
|
||||
@@ -3,15 +3,30 @@ import logging
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from components.debugger.constants import Routes
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.instance_manager import InstanceManager, debug_session
|
||||
|
||||
debugger_app, rt = fast_app()
|
||||
|
||||
logger = logging.getLogger("Debugger")
|
||||
|
||||
|
||||
@rt(Routes.DbEngine)
|
||||
def post(session, _id: str, digest: str = None):
|
||||
logger.debug(f"Entering {Routes.DbEngine} with args {_id=}, {digest=}")
|
||||
@rt(Routes.DbEngineData)
|
||||
def post(session, _id: str, user_id: str, digest: str = None):
|
||||
logger.debug(f"Entering {Routes.DbEngineData} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.add_tab(digest)
|
||||
return instance.add_tab(user_id, digest)
|
||||
|
||||
|
||||
@rt(Routes.JsonViewerFold)
|
||||
def post(session, _id: str, node_id: str, folding: str):
|
||||
logger.debug(f"Entering {Routes.JsonViewerFold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
instance.set_node_folding(node_id, folding)
|
||||
return instance.render_node(node_id)
|
||||
|
||||
@rt(Routes.JsonOpenDigest)
|
||||
def post(session, _id: str, user_id: str, digest: str):
|
||||
logger.debug(f"Entering {Routes.JsonOpenDigest} with args {debug_session(session)}, {_id=}, {user_id=}, {digest=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.open_digest(user_id, digest)
|
||||
|
||||
72
src/components/debugger/assets/Debugger.css
Normal file
72
src/components/debugger/assets/Debugger.css
Normal file
@@ -0,0 +1,72 @@
|
||||
:root:has(input.theme-controller[value=light]:checked),
|
||||
[data-theme="light"] {
|
||||
--json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
|
||||
--json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
|
||||
--json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
|
||||
--json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
|
||||
--json-null: var(--color-base-content);
|
||||
--json-digest: var(--color-base-content);
|
||||
}
|
||||
|
||||
:root:has(input.theme-controller[value=dark]:checked),
|
||||
[data-theme="dark"] {
|
||||
--json-bool: oklch(88.5% 0.062 18.334); /* tailwindcss orange-200 */
|
||||
--json-string: oklch(92.5% 0.084 155.995); /* tailwindcss green-200 */
|
||||
--json-number: oklch(88.2% 0.059 254.128); /* tailwindcss blue-200 */
|
||||
--json-object: oklch(44.4% 0.177 26.899); /* tailwindcss red-800 */
|
||||
--json-null: var(--color-base-content);
|
||||
--json-digest: var(--color-base-content);
|
||||
}
|
||||
|
||||
|
||||
:root:has(input.theme-controller[value=cupcake]:checked),
|
||||
[data-theme="cupcake"] {
|
||||
--json-bool: oklch(75% 0.183 55.934); /* tailwindcss orange-400 */
|
||||
--json-string: oklch(79.2% 0.209 151.711); /* tailwindcss green-400 */
|
||||
--json-number: oklch(70.7% 0.165 254.624); /* tailwindcss blue-400 */
|
||||
--json-object: oklch(57.7% 0.245 27.325); /* tailwindcss red-600 */
|
||||
--json-null: var(--color-base-content); /* tailwindcss violet-400 */
|
||||
--json-digest: var(--color-base-content);
|
||||
}
|
||||
|
||||
|
||||
.mmt-jsonviewer {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Use inherited CSS variables for your custom theme */
|
||||
.mmt-jsonviewer-bool {
|
||||
color: var(--json-bool);
|
||||
}
|
||||
|
||||
.mmt-jsonviewer-string {
|
||||
color: var(--json-string);
|
||||
}
|
||||
|
||||
.mmt-jsonviewer-number {
|
||||
color: var(--json-number);
|
||||
}
|
||||
|
||||
.mmt-jsonviewer-null {
|
||||
color: var(--json-null);
|
||||
}
|
||||
|
||||
.mmt-jsonviewer-digest {
|
||||
color: var(--json-digest);
|
||||
cursor: pointer;
|
||||
|
||||
}
|
||||
|
||||
.mmt-jsonviewer-object {
|
||||
color: var(--json-object);
|
||||
}
|
||||
|
||||
/*:root:has(input.theme-controller[value=dark]:checked),*/
|
||||
/*[data-theme="dark"] {*/
|
||||
/* --json-bool: oklch(40.8% 0.123 38.172); !* tailwindcss orange-900 *!*/
|
||||
/* --json-string: oklch(39.3% 0.095 152.535); !* tailwindcss green-900 *!*/
|
||||
/* --json-number: oklch(37.9% 0.146 265.522); !* tailwindcss blue-900 *!*/
|
||||
/* --json-null: var(--color-base-content);*/
|
||||
/* --json-digest: var(--color-base-content);*/
|
||||
/*}*/
|
||||
@@ -1,5 +1,5 @@
|
||||
// Import the svelte-jsoneditor module
|
||||
import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
|
||||
// import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
|
||||
|
||||
/**
|
||||
* Initializes and displays a JSON editor using the Svelte JSON Editor.
|
||||
|
||||
@@ -7,3 +7,33 @@ icon_dbengine = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="h
|
||||
</path>
|
||||
</g>
|
||||
</svg>""")
|
||||
|
||||
# Fluent CaretRight20Filled
|
||||
icon_collapsed = NotStr("""<svg name="collapsed" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
|
||||
<g fill="none">
|
||||
<path d="M7 14.204a1 1 0 0 0 1.628.778l4.723-3.815a1.5 1.5 0 0 0 0-2.334L8.628 5.02A1 1 0 0 0 7 5.797v8.407z" fill="currentColor">
|
||||
</path>
|
||||
</g>
|
||||
</svg>""")
|
||||
|
||||
# Fluent CaretDown20Filled
|
||||
icon_expanded = NotStr("""<svg name="expanded" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
|
||||
<g fill="none">
|
||||
<path d="M5.797 7a1 1 0 0 0-.778 1.628l3.814 4.723a1.5 1.5 0 0 0 2.334 0l3.815-4.723A1 1 0 0 0 14.204 7H5.797z" fill="currentColor">
|
||||
</path>
|
||||
</g>
|
||||
</svg>""")
|
||||
|
||||
icon_class = NotStr("""
|
||||
<svg name="expanded" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.5" >
|
||||
<polygon points="5,2 2,8 8,8" />
|
||||
<rect x="12" y="2" width="6" height="6"/>
|
||||
<circle cx="5" cy="15" r="3" />
|
||||
<polygon points="11.5,15 15,11.5 18.5,15 15,18.5" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -6,11 +6,40 @@ class Commands:
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
|
||||
|
||||
def show_dbengine(self):
|
||||
def db_engine_data(self, user_id: str):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngine}",
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineData}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}"}}',
|
||||
}
|
||||
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}"}}',
|
||||
}
|
||||
|
||||
def db_engine_refs(self, ref_id: str | None):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineRefs}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}", "ref_id": "{ref_id}"}}',
|
||||
}
|
||||
|
||||
|
||||
class JsonViewerCommands:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
|
||||
def fold(self, node_id: str, folding: str):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.JsonViewerFold}",
|
||||
"hx-target": f"#{node_id}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
|
||||
}
|
||||
|
||||
def open_digest(self, user_id, digest):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.JsonOpenDigest}",
|
||||
"hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
@@ -6,13 +5,15 @@ from fasthtml.components import *
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.debugger.assets.icons import icon_dbengine
|
||||
from components.debugger.commands import Commands
|
||||
from components.debugger.components.DbEngineDebugger import DbEngineDebugger
|
||||
from components.debugger.components.JsonViewer import JsonViewer
|
||||
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
|
||||
from components_helpers import mk_ellipsis, mk_icon
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.utils import get_unique_id
|
||||
|
||||
logger = logging.getLogger("Debugger")
|
||||
|
||||
|
||||
class Debugger(BaseComponent):
|
||||
def __init__(self, session, _id, settings_manager, tabs_manager):
|
||||
super().__init__(session, _id)
|
||||
@@ -21,26 +22,51 @@ class Debugger(BaseComponent):
|
||||
self.tabs_manager = tabs_manager
|
||||
self.commands = Commands(self)
|
||||
|
||||
def add_tab(self, digest):
|
||||
content = self.mk_db_engine(digest)
|
||||
def add_tab(self, user_id, digest):
|
||||
content = self.mk_db_engine_object(user_id, digest)
|
||||
tab_key = f"debugger-dbengine-{digest}"
|
||||
title = f"DBEngine-{digest if digest else 'head'}"
|
||||
self.tabs_manager.add_tab(title, content, key=tab_key)
|
||||
return self.tabs_manager.render()
|
||||
|
||||
def mk_db_engine(self, digest):
|
||||
data = self.db_engine.debug_load(digest) if digest else self.db_engine.debug_head()
|
||||
def mk_db_engine_object(self, user_id, digest):
|
||||
data = self.db_engine.debug_load(user_id, digest) if digest else self.db_engine.debug_head(user_id)
|
||||
|
||||
logger.debug(f"mk_db_engine: {data}")
|
||||
return DbEngineDebugger(self._session, self._id, self, json.dumps(data))
|
||||
return InstanceManager.get(self._session,
|
||||
JsonViewer.create_component_id(self._session, prefix=self._id),
|
||||
JsonViewer,
|
||||
owner=self,
|
||||
user_id=user_id,
|
||||
data=data)
|
||||
|
||||
def mk_db_engine(self, selected):
|
||||
return Div(
|
||||
Input(type="radio",
|
||||
name=f"dbengine-accordion-{self._id}",
|
||||
checked="checked" if selected else None,
|
||||
cls="p-0! min-h-0!",
|
||||
),
|
||||
Div(
|
||||
mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine", cls="text-sm"),
|
||||
cls="collapse-title p-0 min-h-0 flex truncate",
|
||||
),
|
||||
Div(
|
||||
*[Div(user_id, **self.commands.db_engine_data(user_id)) for user_id in self.db_engine.debug_users()],
|
||||
Div("refs", **self.commands.db_engine_refs(None)),
|
||||
cls="collapse-content pr-0! truncate",
|
||||
),
|
||||
cls="collapse mb-2",
|
||||
id=f"db_engine_{self._id}",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
Div(cls="divider"),
|
||||
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
|
||||
Div(
|
||||
mk_icon(icon_dbengine, can_select=False), mk_ellipsis("DbEngine"),
|
||||
self.mk_db_engine(True),
|
||||
cls="flex truncate",
|
||||
**self.commands.show_dbengine(),
|
||||
),
|
||||
|
||||
id=self._id,
|
||||
|
||||
286
src/components/debugger/components/JsonViewer.py
Normal file
286
src/components/debugger/components/JsonViewer.py
Normal file
@@ -0,0 +1,286 @@
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.components import *
|
||||
from pandas import DataFrame
|
||||
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.datagrid_new.components.DataGrid import DataGrid
|
||||
from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
|
||||
from components.debugger.commands import JsonViewerCommands
|
||||
from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
|
||||
from core.serializer import TAG_OBJECT
|
||||
from core.utils import get_unique_id
|
||||
|
||||
|
||||
class FoldingMode:
|
||||
COLLAPSE = "collapse"
|
||||
EXPAND = "expand"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Node:
|
||||
value: Any
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ValueNode(Node):
|
||||
hint: str = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ListNode(Node):
|
||||
node_id: str
|
||||
level: int
|
||||
children: list[Node] = dataclasses.field(default_factory=list)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DictNode(Node):
|
||||
node_id: str
|
||||
level: int
|
||||
children: dict[str, Node] = dataclasses.field(default_factory=dict)
|
||||
|
||||
|
||||
class JsonViewer(BaseComponent):
|
||||
def __init__(self, session, _id, owner, user_id, data):
|
||||
super().__init__(session, _id)
|
||||
self._owner = owner # debugger component
|
||||
self.user_id = user_id
|
||||
self.data = data
|
||||
self._node_id = -1
|
||||
self._commands = JsonViewerCommands(self)
|
||||
|
||||
# A little explanation on how the folding / unfolding work
|
||||
# all the nodes are either fold or unfold... except if there are not
|
||||
# self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
|
||||
# self._nodes_to_track keeps track of the exceptions
|
||||
# The idea is to minimize the memory usage
|
||||
self._folding_mode = FoldingMode.COLLAPSE
|
||||
self._nodes_to_track = set() # all nodes that are expanded when _fold_mode and vice versa
|
||||
|
||||
self._nodes_by_id = {}
|
||||
|
||||
self.node = self._create_node(None, data)
|
||||
|
||||
def set_node_folding(self, node_id, folding):
|
||||
if folding == self._folding_mode:
|
||||
self._nodes_to_track.remove(node_id)
|
||||
else:
|
||||
self._nodes_to_track.add(node_id)
|
||||
|
||||
def render_node(self, node_id):
|
||||
key, node = self._nodes_by_id[node_id]
|
||||
return self._render_node(key, node)
|
||||
|
||||
def set_folding_mode(self, folding_mode):
|
||||
self._folding_mode = folding_mode
|
||||
self._nodes_to_track.clear()
|
||||
|
||||
def get_folding_mode(self):
|
||||
return self._folding_mode
|
||||
|
||||
def get_owner(self):
|
||||
return self._owner
|
||||
|
||||
def open_digest(self, user_id: str, digest: str):
|
||||
return self._owner.add_tab(user_id, digest)
|
||||
|
||||
def _create_node(self, key, data, level=0):
|
||||
if isinstance(data, list):
|
||||
node_id = self._get_next_id()
|
||||
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
|
||||
self._nodes_to_track.add(node_id)
|
||||
node = ListNode(data, node_id, level)
|
||||
self._nodes_by_id[node_id] = (key, node)
|
||||
for index, item in enumerate(data):
|
||||
node.children.append(self._create_node(index, item, level + 1))
|
||||
|
||||
elif isinstance(data, dict):
|
||||
node_id = self._get_next_id()
|
||||
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
|
||||
self._nodes_to_track.add(node_id)
|
||||
node = DictNode(data, node_id, level)
|
||||
self._nodes_by_id[node_id] = (key, node)
|
||||
for key, value in data.items():
|
||||
node.children[key] = self._create_node(key, value, level + 1)
|
||||
|
||||
else:
|
||||
if key == TAG_OBJECT:
|
||||
hint = NODE_OBJECT
|
||||
else:
|
||||
hint = None
|
||||
node = ValueNode(data, hint)
|
||||
|
||||
return node
|
||||
|
||||
def _must_expand(self, node):
|
||||
if self._folding_mode == FoldingMode.COLLAPSE:
|
||||
return node.node_id in self._nodes_to_track
|
||||
else:
|
||||
return node.node_id not in self._nodes_to_track
|
||||
|
||||
def _mk_folding(self, node: Node):
|
||||
if not isinstance(node, (ListNode, DictNode)):
|
||||
return None
|
||||
|
||||
must_expand = self._must_expand(node)
|
||||
|
||||
return Span(icon_expanded if must_expand else icon_collapsed,
|
||||
cls="icon-16-inline mmt-jsonviewer-folding",
|
||||
style=f"margin-left: -{INDENT_SIZE}px;",
|
||||
**self._commands.fold(node.node_id, FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND)
|
||||
)
|
||||
|
||||
def _get_next_id(self):
|
||||
self._node_id += 1
|
||||
return f"{self._id}-{self._node_id}"
|
||||
|
||||
def _render_value(self, node):
|
||||
def _is_sha256(_value):
|
||||
return isinstance(_value, str) and len(_value) == 64 and all(
|
||||
c in '0123456789abcdefABCDEF' for c in _value)
|
||||
|
||||
if isinstance(node, DictNode):
|
||||
return self._render_dict(node)
|
||||
elif isinstance(node, ListNode):
|
||||
return self._render_list(node)
|
||||
else:
|
||||
data_tooltip = None
|
||||
htmx_params = {}
|
||||
icon = None
|
||||
|
||||
if isinstance(node.value, bool): # order is important bool is an int in Python !
|
||||
str_value = "true" if node.value else "false"
|
||||
data_class = "bool"
|
||||
elif isinstance(node.value, (int, float)):
|
||||
str_value = str(node.value)
|
||||
data_class = "number"
|
||||
elif node.value is None:
|
||||
str_value = "null"
|
||||
data_class = "null"
|
||||
elif _is_sha256(node.value):
|
||||
str_value = str(node.value)
|
||||
data_class = "digest"
|
||||
htmx_params = self._commands.open_digest(self.user_id, node.value)
|
||||
elif node.hint == NODE_OBJECT:
|
||||
icon = icon_class
|
||||
str_value = node.value.split(".")[-1]
|
||||
data_class = "object"
|
||||
elif isinstance(node.value, DataFrame):
|
||||
dg = DataGrid(self._session)
|
||||
dg.init_from_dataframe(node.value)
|
||||
str_value = dg
|
||||
data_class = "dataframe"
|
||||
else:
|
||||
as_str = str(node.value)
|
||||
if len(as_str) > MAX_TEXT_LENGTH:
|
||||
str_value = as_str[:MAX_TEXT_LENGTH] + "..."
|
||||
data_tooltip = as_str
|
||||
else:
|
||||
str_value = as_str
|
||||
str_value = self.add_quotes(str_value)
|
||||
data_class = "string"
|
||||
|
||||
if data_tooltip is not None:
|
||||
cls = f"mmt-jsonviewer-{data_class} mmt-tooltip"
|
||||
else:
|
||||
cls = f"mmt-jsonviewer-{data_class}"
|
||||
|
||||
if icon is not None:
|
||||
return Span(Span(icon, cls="icon-16-inline mr-1"),
|
||||
Span(str_value, data_tooltip=data_tooltip, **htmx_params),
|
||||
cls=cls)
|
||||
|
||||
return Span(str_value, cls=cls, data_tooltip=data_tooltip, **htmx_params)
|
||||
|
||||
def _render_dict(self, node: DictNode):
|
||||
if self._must_expand(node):
|
||||
return Span("{",
|
||||
*[
|
||||
self._render_node(key, value)
|
||||
for key, value in node.children.items()
|
||||
],
|
||||
Div("}"),
|
||||
id=node.node_id)
|
||||
else:
|
||||
return Span("{...}", id=node.node_id)
|
||||
|
||||
def _render_list(self, node: ListNode):
|
||||
def _all_the_same(_node):
|
||||
if len(_node.children) == 0:
|
||||
return False
|
||||
|
||||
sample_value = _node.children[0].value
|
||||
|
||||
if sample_value is None:
|
||||
return False
|
||||
|
||||
type_ = type(sample_value)
|
||||
if type_ in (int, float, str, bool, list, dict, ValueNode):
|
||||
return False
|
||||
|
||||
return all(type(item.value) == type_ for item in _node.children)
|
||||
|
||||
def _render_as_grid(_node):
|
||||
type_ = type(_node.children[0].value)
|
||||
icon = icon_class
|
||||
str_value = type_.__name__.split(".")[-1]
|
||||
|
||||
data = [child.value.__dict__ for child in _node.children]
|
||||
df = DataFrame(data)
|
||||
dg = DataGrid(self._session)
|
||||
dg.init_from_dataframe(df)
|
||||
return Span(Span(Span(icon, cls="icon-16-inline mr-1"), Span(str_value), cls="mmt-jsonviewer-object"),
|
||||
dg,
|
||||
id=_node.node_id)
|
||||
|
||||
def _render_as_list(_node):
|
||||
return Span("[",
|
||||
*[
|
||||
self._render_node(index, item)
|
||||
for index, item in enumerate(_node.children)
|
||||
],
|
||||
Div("]"),
|
||||
)
|
||||
|
||||
if self._must_expand(node):
|
||||
if _all_the_same(node):
|
||||
return _render_as_grid(node)
|
||||
return _render_as_list(node)
|
||||
else:
|
||||
return Span("[...]", id=node.node_id)
|
||||
|
||||
def _render_node(self, key, node):
|
||||
return Div(
|
||||
self._mk_folding(node),
|
||||
Span(f'{key} : ') if key is not None else None,
|
||||
self._render_value(node),
|
||||
style=f"margin-left: {INDENT_SIZE}px;",
|
||||
id=node.node_id if hasattr(node, "node_id") else None,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
Div(self._render_node(None, self.node), id=f"{self._id}-root"),
|
||||
cls="mmt-jsonviewer",
|
||||
id=f"{self._id}")
|
||||
|
||||
@staticmethod
|
||||
def add_quotes(value: str):
|
||||
if '"' in value and "'" in value:
|
||||
# Value contains both double and single quotes, escape double quotes
|
||||
return f'"{value.replace("\"", "\\\"")}"'
|
||||
elif '"' in value:
|
||||
# Value contains double quotes, use single quotes
|
||||
return f"'{value}'"
|
||||
else:
|
||||
# Default case, use double quotes
|
||||
return f'"{value}"'
|
||||
|
||||
@staticmethod
|
||||
def create_component_id(session, prefix=None, suffix=None):
|
||||
if suffix is None:
|
||||
suffix = get_unique_id()
|
||||
|
||||
return f"{prefix}{suffix}"
|
||||
@@ -1,6 +1,14 @@
|
||||
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
|
||||
ROUTE_ROOT = "/debugger"
|
||||
|
||||
INDENT_SIZE = 20
|
||||
MAX_TEXT_LENGTH = 50
|
||||
|
||||
NODE_OBJECT = "Object"
|
||||
NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
|
||||
|
||||
class Routes:
|
||||
DbEngine = "/dbengine" # request the filtering in the grid
|
||||
|
||||
DbEngineData = "/dbengine-data"
|
||||
DbEngineRefs = "/dbengine-refs"
|
||||
JsonViewerFold = "/jsonviewer-fold"
|
||||
JsonOpenDigest = "/jsonviewer-open-digest"
|
||||
|
||||
@@ -12,22 +12,35 @@ main {
|
||||
}
|
||||
|
||||
.dl-main {
|
||||
flex-grow: 1; /* Ensures it grows to fill available space */
|
||||
height: 100%; /* Inherit height from its parent */
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
flex-direction: column; /* Stack children vertically */
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
}
|
||||
|
||||
.dl-main label {
|
||||
align-self: flex-start; /* Aligns the label to the left */
|
||||
}
|
||||
|
||||
|
||||
.dl-main:focus {
|
||||
outline: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dl-page {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.dl-sidebar {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
flex-shrink: 0; /* Prevent sidebar from shrinking */
|
||||
flex-grow: 0; /* Disable growth (optional for better control) */
|
||||
transition: width 0.2s ease;
|
||||
height: 100%; /* Makes the sidebar height span the entire viewport */
|
||||
position: relative;
|
||||
width: 150px;
|
||||
flex-shrink: 0; /* Prevent sidebar from shrinking */
|
||||
flex-grow: 0; /* Disable growth (optional for better control) */
|
||||
transition: width 0.2s ease;
|
||||
height: 100%; /* Makes the sidebar height span the entire viewport */
|
||||
}
|
||||
|
||||
.dl-sidebar.collapsed {
|
||||
|
||||
@@ -3,10 +3,10 @@ from fasthtml.xtend import Script
|
||||
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.addstuff.components.AddStuffMenu import AddStuffMenu
|
||||
from components.addstuff.components.Repositories import Repositories
|
||||
from components.debugger.components.Debugger import Debugger
|
||||
from components.drawerlayout.assets.icons import icon_panel_contract_regular, icon_panel_expand_regular
|
||||
from components.drawerlayout.constants import DRAWER_LAYOUT_INSTANCE_ID
|
||||
from components.repositories.components.Repositories import Repositories
|
||||
from components.tabs.components.MyTabs import MyTabs
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.settings_management import SettingsManager
|
||||
@@ -18,22 +18,11 @@ class DrawerLayout(BaseComponent):
|
||||
_id: str = None,
|
||||
settings_manager: SettingsManager = None):
|
||||
super().__init__(session, _id)
|
||||
self._settings_manager = settings_manager
|
||||
self._tabs = InstanceManager.get(session, MyTabs.create_component_id(session), MyTabs)
|
||||
self._add_stuff = InstanceManager.get(session,
|
||||
AddStuffMenu.create_component_id(session),
|
||||
AddStuffMenu,
|
||||
settings_manager=settings_manager,
|
||||
tabs_manager=self._tabs)
|
||||
self._repositories = InstanceManager.get(session,
|
||||
Repositories.create_component_id(session),
|
||||
Repositories,
|
||||
settings_manager=settings_manager,
|
||||
tabs_manager=self._tabs)
|
||||
self._debugger = InstanceManager.get(session,
|
||||
Debugger.create_component_id(session),
|
||||
Debugger,
|
||||
settings_manager=settings_manager,
|
||||
tabs_manager=self._tabs)
|
||||
self._repositories = self._create_component(Repositories)
|
||||
self._debugger = self._create_component(Debugger)
|
||||
self._add_stuff = self._create_component(AddStuffMenu)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
@@ -57,13 +46,21 @@ class DrawerLayout(BaseComponent):
|
||||
cls="swap",
|
||||
),
|
||||
|
||||
Div(self._tabs, id=f"page_{self._id}", name="page"),
|
||||
Div(self._tabs, id=f"page_{self._id}", name="page", cls='dl-page'),
|
||||
cls='dl-main',
|
||||
tabindex="0",
|
||||
),
|
||||
cls="dl-container flex"
|
||||
), Script(f"bindDrawerLayout('{self._id}')")
|
||||
|
||||
def _create_component(self, component_type: type):
|
||||
safe_create_component_id = getattr(component_type, "create_component_id")
|
||||
return InstanceManager.get(self._session,
|
||||
safe_create_component_id(self._session),
|
||||
component_type,
|
||||
settings_manager=self._settings_manager,
|
||||
tabs_manager=self._tabs)
|
||||
|
||||
@staticmethod
|
||||
def create_component_id(session, suffix: str = ""):
|
||||
return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"
|
||||
|
||||
@@ -54,10 +54,5 @@ def post(session, email: str, password: str):
|
||||
# Log in user by setting session data
|
||||
AuthManager.login_user(session, user_data)
|
||||
|
||||
# Make sure that the settings are created for this user
|
||||
user_id = user_data["id"]
|
||||
user_email = user_data["email"]
|
||||
instance.init_user(user_id, user_email)
|
||||
|
||||
# Redirect to home page
|
||||
return RedirectResponse('/', status_code=303)
|
||||
|
||||
@@ -26,9 +26,6 @@ class Login:
|
||||
self.error_message = error_message
|
||||
return self.__ft__()
|
||||
|
||||
def init_user(self, user_id: str, user_email: str):
|
||||
return self.settings_manager.init_user(user_id, user_email)
|
||||
|
||||
def __ft__(self):
|
||||
# Create alert for error or success message
|
||||
message_alert = None
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from fasthtml.components import *
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
import config
|
||||
from components.footer import footer
|
||||
from components.header.components.MyHeader import MyHeader
|
||||
from components_helpers import mk_tooltip_container
|
||||
|
||||
|
||||
def page_layout_new(session, settings_manager, content):
|
||||
@@ -24,9 +26,12 @@ def page_layout_lite(session, settings_manager, content):
|
||||
return (
|
||||
Title(f"{config.APP_NAME}"),
|
||||
Div(
|
||||
mk_tooltip_container("mmt-app"),
|
||||
MyHeader(session, settings_manager),
|
||||
Main(content, cls="flex-grow"),
|
||||
footer(),
|
||||
cls="flex flex-col min-h-screen"
|
||||
Script("bindTooltipsWithDelegation();"),
|
||||
cls="flex flex-col min-h-screen",
|
||||
id="mmt-app",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from components.addstuff.components.Repositories import Repositories
|
||||
from components.addstuff.constants import Routes
|
||||
from components.repositories.components.Repositories import Repositories
|
||||
from components.repositories.constants import Routes
|
||||
from core.instance_manager import InstanceManager, debug_session
|
||||
|
||||
logger = logging.getLogger("AddStuffApp")
|
||||
|
||||
add_stuff_app, rt = fast_app()
|
||||
repositories_app, rt = fast_app()
|
||||
|
||||
|
||||
@rt(Routes.AddRepository)
|
||||
@@ -19,11 +20,11 @@ def get(session):
|
||||
|
||||
|
||||
@rt(Routes.AddRepository)
|
||||
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str):
|
||||
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str, tab_boundaries:str):
|
||||
logger.debug(
|
||||
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}")
|
||||
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}, {tab_boundaries=}")
|
||||
instance = InstanceManager.get(session, _id) # Repository
|
||||
return instance.add_new_repository(tab_id, form_id, repository, table)
|
||||
return instance.add_new_repository(tab_id, form_id, repository, table, json.loads(tab_boundaries))
|
||||
|
||||
|
||||
@rt(Routes.AddTable)
|
||||
@@ -33,9 +34,10 @@ def get(session, _id: str, repository_name: str):
|
||||
|
||||
|
||||
@rt(Routes.AddTable)
|
||||
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str):
|
||||
def post(session, _id: str, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries:str):
|
||||
logger.debug(f"Entering {Routes.AddTable} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository_name=}, {table_name=}, {tab_boundaries=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.add_new_table(tab_id, form_id, repository_name, table_name)
|
||||
return instance.add_new_table(tab_id, form_id, repository_name, table_name, json.loads(tab_boundaries))
|
||||
|
||||
|
||||
@rt(Routes.SelectRepository)
|
||||
@@ -46,7 +48,7 @@ def put(session, _id: str, repository: str):
|
||||
|
||||
|
||||
@rt(Routes.ShowTable)
|
||||
def get(session, _id: str, repository: str, table: str):
|
||||
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}")
|
||||
def get(session, _id: str, repository: str, table: str, tab_boundaries:str):
|
||||
logger.debug(f"Entering {Routes.ShowTable} with args {debug_session(session)}, {_id=}, {repository=}, {table=}, {tab_boundaries=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
return instance.show_table(repository, table)
|
||||
return instance.show_table(repository, table, json.loads(tab_boundaries))
|
||||
0
src/components/repositories/assets/__init__.py
Normal file
0
src/components/repositories/assets/__init__.py
Normal file
48
src/components/repositories/commands.py
Normal file
48
src/components/repositories/commands.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from components.repositories.constants import ROUTE_ROOT, Routes
|
||||
|
||||
|
||||
class Commands:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
|
||||
def request_add_repository(self):
|
||||
return {
|
||||
"hx-get": f"{ROUTE_ROOT}{Routes.AddRepository}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}"}}',
|
||||
}
|
||||
|
||||
def add_repository(self, tab_id: str):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
|
||||
"hx-target": f"#{self._id}",
|
||||
"hx-swap": "beforeend",
|
||||
"hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
|
||||
}
|
||||
|
||||
def request_add_table(self, repository_name):
|
||||
return {
|
||||
"hx-get": f"{ROUTE_ROOT}{Routes.AddTable}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'{{"_id": "{self._id}", "repository_name": "{repository_name}"}}',
|
||||
}
|
||||
|
||||
def add_table(self, tab_id: str, repository_name: str):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.AddTable}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
# The repository_name and the table_name will be given by the form
|
||||
"hx-vals": f'js:{{"_id": "{self._id}", "tab_id": "{tab_id}", "repository_name": "{repository_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
|
||||
}
|
||||
|
||||
def show_table(self, repo_name, table_name):
|
||||
return {
|
||||
"hx_get": f"{ROUTE_ROOT}{Routes.ShowTable}",
|
||||
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": f'js:{{"_id": "{self._id}", "repository": "{repo_name}", "table": "{table_name}", "tab_boundaries": getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}',
|
||||
}
|
||||
@@ -4,14 +4,15 @@ from fasthtml.components import *
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.addstuff.assets.icons import icon_database, icon_table
|
||||
from components.addstuff.commands import Commands
|
||||
from components.addstuff.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
|
||||
from components.addstuff.settings import RepositoriesDbManager, Repository
|
||||
from components.datagrid_new.components.DataGrid import DataGrid
|
||||
from components.form.components.MyForm import MyForm, FormField
|
||||
from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
|
||||
from components.repositories.assets.icons import icon_database, icon_table
|
||||
from components.repositories.commands import Commands
|
||||
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
|
||||
from components.repositories.db_management import RepositoriesDbManager, Repository
|
||||
from components_helpers import mk_icon, mk_ellipsis
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.utils import make_safe_id
|
||||
|
||||
logger = logging.getLogger("Repositories")
|
||||
|
||||
@@ -19,11 +20,11 @@ logger = logging.getLogger("Repositories")
|
||||
class Repositories(BaseComponent):
|
||||
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
||||
super().__init__(session, _id)
|
||||
self._settings_manager = settings_manager
|
||||
self.db = RepositoriesDbManager(session, settings_manager)
|
||||
self.commands = Commands(self)
|
||||
self.tabs_manager = tabs_manager
|
||||
self.db = RepositoriesDbManager(session, settings_manager)
|
||||
self._settings_manager = settings_manager
|
||||
self._contents = {} # ket tracks of already displayed contents
|
||||
self._commands = Commands(self)
|
||||
|
||||
def request_new_repository(self):
|
||||
# request for a new tab_id
|
||||
@@ -45,26 +46,27 @@ class Repositories(BaseComponent):
|
||||
|
||||
# create and display the form in a new tab
|
||||
self.tabs_manager.add_tab("Add New Table", add_table_form, tab_id=new_tab_id)
|
||||
return self.tabs_manager
|
||||
return self.tabs_manager.refresh()
|
||||
|
||||
def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
|
||||
def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
|
||||
"""
|
||||
|
||||
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
|
||||
:param form_id: form used to give the repository name (to be used in case of error)
|
||||
:param repository_name: new repository name
|
||||
:param table_name: default table name
|
||||
:param tab_boundaries: tab boundaries
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
# Add the new repository and its default table to the list of repositories
|
||||
tables = [MyTable(table_name, {})] if table_name else []
|
||||
tables = [table_name] if table_name else []
|
||||
repository = self.db.add_repository(repository_name, tables)
|
||||
|
||||
# update the tab content with table content
|
||||
key = (repository_name, table_name)
|
||||
self.tabs_manager.set_tab_content(tab_id,
|
||||
self._get_table_content(key),
|
||||
self._get_table_content(key, tab_boundaries),
|
||||
title=table_name,
|
||||
key=key,
|
||||
active=True)
|
||||
@@ -78,27 +80,28 @@ class Repositories(BaseComponent):
|
||||
|
||||
return self.tabs_manager.refresh()
|
||||
|
||||
def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
|
||||
def add_new_table(self, tab_id: str, form_id: str, repository_name: str, table_name: str, tab_boundaries: dict):
|
||||
"""
|
||||
|
||||
:param tab_id: tab id where the table content will be displayed (and where the form was displayed)
|
||||
:param form_id: form used to give the repository name (to be used in case of error)
|
||||
:param repository_name: new repository name
|
||||
:param table_name: default table name
|
||||
:param tab_boundaries: tab boundaries
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
self.db.add_table(repository_name, table_name, {})
|
||||
self.db.add_table(repository_name, table_name)
|
||||
repository = self.db.get_repository(repository_name)
|
||||
# update the tab content with table content
|
||||
key = (repository_name, table_name)
|
||||
self.tabs_manager.set_tab_content(tab_id,
|
||||
self._get_table_content(key),
|
||||
self._get_table_content(key, tab_boundaries),
|
||||
title=table_name,
|
||||
key=key,
|
||||
active=True)
|
||||
|
||||
return self._mk_repository(repository, True), self.tabs_manager.refresh()
|
||||
return self._mk_repository(repository, True, oob=True), self.tabs_manager.refresh()
|
||||
|
||||
except ValueError as ex:
|
||||
logger.debug(f" Repository '{repository_name}' already exists.")
|
||||
@@ -110,33 +113,33 @@ class Repositories(BaseComponent):
|
||||
def select_repository(self, repository_name: str):
|
||||
self.db.select_repository(repository_name)
|
||||
|
||||
def show_table(self, repository_name: str, table_name: str):
|
||||
def show_table(self, repository_name: str, table_name: str, tab_boundaries: dict):
|
||||
key = (repository_name, table_name)
|
||||
self.tabs_manager.add_tab(table_name, self._get_table_content(key), key)
|
||||
return self.tabs_manager
|
||||
self.tabs_manager.add_tab(table_name, self._get_table_content(key, tab_boundaries), key)
|
||||
return self.tabs_manager.refresh()
|
||||
|
||||
def refresh(self):
|
||||
return self._mk_repositories(oob=True)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
mk_tooltip_container(self._id),
|
||||
Div(cls="divider"),
|
||||
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),
|
||||
self._mk_repositories(),
|
||||
Script(f"bindRepositories('{self._id}')")
|
||||
)
|
||||
|
||||
def _mk_repositories(self, oob=False):
|
||||
settings = self.db._get_settings()
|
||||
repositories = self.db.get_repositories()
|
||||
selected_repository_name = self.db.get_selected_repository()
|
||||
return Div(
|
||||
*[self._mk_repository(repo, repo.name == settings.selected_repository_name)
|
||||
for repo in settings.repositories],
|
||||
*[self._mk_repository(repo, repo.name == selected_repository_name)
|
||||
for repo in repositories],
|
||||
Div("+ Add Repository", cls="text-sm", **self.commands.request_add_repository()),
|
||||
id=self._id,
|
||||
hx_swap_oob="true" if oob else None,
|
||||
)
|
||||
|
||||
def _mk_repository(self, repo: Repository, selected):
|
||||
def _mk_repository(self, repo: Repository, selected, oob=False) -> Div:
|
||||
return Div(
|
||||
Input(type="radio",
|
||||
name=f"repo-accordion-{self._id}",
|
||||
@@ -147,59 +150,59 @@ class Repositories(BaseComponent):
|
||||
# hx_trigger="changed delay:500ms",
|
||||
),
|
||||
Div(
|
||||
mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name),
|
||||
mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name, cls="text-sm"),
|
||||
cls="collapse-title p-0 min-h-0 flex truncate",
|
||||
),
|
||||
Div(
|
||||
*[
|
||||
Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table.name),
|
||||
Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table_name, cls="text-sm"),
|
||||
name="repo-table",
|
||||
hx_get=f"{ROUTE_ROOT}{Routes.ShowTable}",
|
||||
hx_target=f"#{self.tabs_manager.get_id()}",
|
||||
hx_swap="outerHTML",
|
||||
hx_vals=f'{{"_id": "{self._id}", "repository": "{repo.name}", "table": "{table.name}"}}',
|
||||
cls="flex")
|
||||
for table in repo.tables
|
||||
cls="flex",
|
||||
**self.commands.show_table(repo.name, table_name))
|
||||
for table_name in repo.tables
|
||||
],
|
||||
Div("+ Add Table", **self._commands.request_add_table(repo.name)),
|
||||
Div("+ Add Table", cls="text-sm", **self.commands.request_add_table(repo.name)),
|
||||
cls="collapse-content pr-0! truncate",
|
||||
),
|
||||
tabindex="0", cls="collapse mb-2")
|
||||
tabindex="0",
|
||||
cls="collapse mb-2",
|
||||
id=f"repo-{self._id}-{make_safe_id(repo.name)}",
|
||||
hx_swap_oob="true" if oob else None,
|
||||
)
|
||||
|
||||
def _mk_add_repository_form(self, tab_id: str):
|
||||
htmx_params = {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.AddRepository}",
|
||||
"hx-target": f"#{self._id}",
|
||||
"hx-swap": "beforeend",
|
||||
}
|
||||
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
|
||||
title="Add Repository",
|
||||
fields=[FormField("repository", 'Repository Name', 'input'),
|
||||
FormField("table", 'First Table Name', 'input')],
|
||||
htmx_params=htmx_params,
|
||||
extra_values={"_id": self._id, "tab_id": tab_id})
|
||||
htmx_request=self.commands.add_repository(tab_id),
|
||||
)
|
||||
|
||||
def _mk_add_table_form(self, tab_id: str, repository_name: str = None):
|
||||
htmx_request = self._commands.add_table()
|
||||
|
||||
return InstanceManager.get(self._session, MyForm.create_component_id(self._session), MyForm,
|
||||
title="Add Table",
|
||||
fields=[FormField("repository_name", 'Repository Name', 'input',
|
||||
value=repository_name,
|
||||
disabled=True),
|
||||
FormField("table_name", 'Table Name', 'input')],
|
||||
htmx_request=htmx_request,
|
||||
extra_values={"_id": self._id, "tab_id": tab_id, "repository_name": repository_name})
|
||||
htmx_request=self.commands.add_table(tab_id, repository_name),
|
||||
)
|
||||
|
||||
def _get_table_content(self, key):
|
||||
def _get_table_content(self, key, tab_boundaries: dict):
|
||||
|
||||
if key in self._contents:
|
||||
return self._contents[key]
|
||||
content = self._contents[key]
|
||||
if hasattr(content, "set_boundaries"):
|
||||
content.set_boundaries(tab_boundaries)
|
||||
return content
|
||||
|
||||
dg = InstanceManager.get(self._session,
|
||||
DataGrid.create_component_id(self._session),
|
||||
DataGrid,
|
||||
settings_manager=self._settings_manager,
|
||||
key=key)
|
||||
key=key,
|
||||
boundaries=tab_boundaries)
|
||||
|
||||
self._contents[key] = dg
|
||||
return dg
|
||||
0
src/components/repositories/components/__init__.py
Normal file
0
src/components/repositories/components/__init__.py
Normal file
8
src/components/repositories/constants.py
Normal file
8
src/components/repositories/constants.py
Normal file
@@ -0,0 +1,8 @@
|
||||
REPOSITORIES_INSTANCE_ID = "__Repositories__"
|
||||
ROUTE_ROOT = "/repositories"
|
||||
|
||||
class Routes:
|
||||
AddRepository = "/add-repo"
|
||||
SelectRepository = "/select-repo"
|
||||
AddTable = "/add-table"
|
||||
ShowTable = "/show-table"
|
||||
185
src/components/repositories/db_management.py
Normal file
185
src/components/repositories/db_management.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
|
||||
from core.settings_management import SettingsManager
|
||||
|
||||
REPOSITORIES_SETTINGS_ENTRY = "Repositories"
|
||||
|
||||
logger = logging.getLogger("AddStuffSettings")
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Repository:
|
||||
name: str
|
||||
tables: list[str]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RepositoriesSettings:
|
||||
repositories: list[Repository] = dataclasses.field(default_factory=list)
|
||||
selected_repository_name: str = None
|
||||
|
||||
@staticmethod
|
||||
def use_refs():
|
||||
return {"repositories"}
|
||||
|
||||
|
||||
class RepositoriesDbManager:
|
||||
|
||||
def __init__(self, session: dict, settings_manager: SettingsManager):
|
||||
self.session = session
|
||||
self.settings_manager = settings_manager
|
||||
|
||||
def add_repository(self, repository_name: str, tables: list[str] = None):
|
||||
"""
|
||||
Adds a new repository to the list of repositories. The repository is identified
|
||||
by its name and can optionally include a list of associated tables.
|
||||
|
||||
:param repository_name: The name of the repository to be added.
|
||||
:param tables: A list of Table objects to be associated with the repository,
|
||||
defaulting to an empty list if not provided.
|
||||
:type tables: list[Table], optional
|
||||
:return: None
|
||||
"""
|
||||
|
||||
settings = self._get_settings()
|
||||
|
||||
if repository_name is None or repository_name == "":
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
if repository_name in [repo.name for repo in settings.repositories]:
|
||||
raise ValueError(f"Repository '{repository_name}' already exists.")
|
||||
|
||||
existing_repositories = [r.name for r in settings.repositories]
|
||||
logger.info(f"Existing repositories:{existing_repositories}")
|
||||
|
||||
repository = Repository(repository_name, tables or [])
|
||||
settings.repositories.append(repository)
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
return repository
|
||||
|
||||
def get_repository(self, repository_name: str):
|
||||
if repository_name is None or repository_name == "":
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
settings = self._get_settings()
|
||||
if repository_name not in [repo.name for repo in settings.repositories]:
|
||||
raise ValueError(f"Repository '{repository_name}' does not exist.")
|
||||
|
||||
return next(filter(lambda r: r.name == repository_name, settings.repositories))
|
||||
|
||||
def modify_repository(self, old_repository_name, new_repository_name: str, tables: list[str]):
|
||||
if not old_repository_name or not new_repository_name:
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
settings = self._get_settings()
|
||||
for repository in settings.repositories:
|
||||
if repository.name == old_repository_name:
|
||||
repository.name = new_repository_name
|
||||
repository.tables = tables
|
||||
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
return repository
|
||||
|
||||
else:
|
||||
raise ValueError(f"Repository '{old_repository_name}' not found.")
|
||||
|
||||
def remove_repository(self, repository_name):
|
||||
if not repository_name:
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
settings = self._get_settings()
|
||||
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
|
||||
if repository is None:
|
||||
raise ValueError(f"Repository '{repository_name}' does not exist.")
|
||||
|
||||
settings.repositories.remove(repository)
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
return repository
|
||||
|
||||
def get_repositories(self):
|
||||
return self._get_settings().repositories
|
||||
|
||||
def add_table(self, repository_name: str, table_name: str):
|
||||
"""
|
||||
Adds a table to the specified repository
|
||||
|
||||
:param repository_name: The name of the target repository.
|
||||
:param table_name: The name of the table to add.
|
||||
:return: None
|
||||
"""
|
||||
settings, repository = self._get_settings_and_repo(repository_name, table_name, t1_must_exists=False)
|
||||
|
||||
repository.tables.append(table_name)
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
|
||||
def modify_table(self, repository_name: str, old_table_name: str, new_table_name: str):
|
||||
"""
|
||||
Modifies the name of a table in the specified repository.
|
||||
|
||||
:param repository_name: The name of the repository containing the table.
|
||||
:param old_table_name: The current name of the table to be modified.
|
||||
:param new_table_name: The new name for the table.
|
||||
:return: None
|
||||
"""
|
||||
settings, repository = self._get_settings_and_repo(repository_name, old_table_name, new_table_name)
|
||||
|
||||
table_index = repository.tables.index(old_table_name)
|
||||
repository.tables[table_index] = new_table_name
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
|
||||
def remove_table(self, repository_name: str, table_name: str):
|
||||
"""
|
||||
Removes a table from the specified repository.
|
||||
|
||||
:param repository_name: The name of the repository containing the table.
|
||||
:param table_name: The name of the table to be removed.
|
||||
:return: None
|
||||
"""
|
||||
settings, repository = self._get_settings_and_repo(repository_name, table_name)
|
||||
|
||||
repository.tables.remove(table_name)
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
|
||||
def select_repository(self, repository_name: str):
|
||||
"""
|
||||
Select and save the specified repository name in the current session's settings.
|
||||
|
||||
:param repository_name: The name of the repository to be selected and stored.
|
||||
:type repository_name: str
|
||||
:return: None
|
||||
"""
|
||||
settings = self._get_settings()
|
||||
settings.selected_repository_name = repository_name
|
||||
self.settings_manager.save(self.session, REPOSITORIES_SETTINGS_ENTRY, settings)
|
||||
|
||||
def get_selected_repository(self):
|
||||
settings = self._get_settings()
|
||||
return settings.selected_repository_name
|
||||
|
||||
def _get_settings(self):
|
||||
return self.settings_manager.load(self.session, REPOSITORIES_SETTINGS_ENTRY, default=RepositoriesSettings())
|
||||
|
||||
def _get_settings_and_repo(self, repository_name, *tables_names, t1_must_exists=True, t2_must_exists=False):
|
||||
|
||||
if not repository_name:
|
||||
raise ValueError("Repository name cannot be empty.")
|
||||
|
||||
for table_name in tables_names:
|
||||
if not table_name:
|
||||
raise ValueError("Table name cannot be empty.")
|
||||
|
||||
settings = self._get_settings()
|
||||
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
|
||||
if repository is None:
|
||||
raise ValueError(f"Repository '{repository_name}' does not exist.")
|
||||
|
||||
for table_name, must_exist in zip(tables_names, [t1_must_exists, t2_must_exists]):
|
||||
if must_exist:
|
||||
if table_name not in repository.tables:
|
||||
raise ValueError(f"Table '{table_name}' does not exist in repository '{repository_name}'.")
|
||||
else:
|
||||
if table_name in repository.tables:
|
||||
raise ValueError(f"Table '{table_name}' already exists in repository '{repository_name}'.")
|
||||
|
||||
return settings, repository
|
||||
@@ -1,20 +1,27 @@
|
||||
.tabs {
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.mmt-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tabs-content {
|
||||
.mmt-tabs-header {
|
||||
display: flex;
|
||||
min-height: 25px;
|
||||
}
|
||||
|
||||
.mmt-tabs-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tabs-tab {
|
||||
.mmt-tabs-tab {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
text-align: center;
|
||||
@@ -28,15 +35,15 @@
|
||||
|
||||
}
|
||||
|
||||
.tabs-tab:hover {
|
||||
.mmt-tabs-tab:hover {
|
||||
color: var(--color-base-content); /* Change text color on hover */
|
||||
}
|
||||
|
||||
.tabs-label {
|
||||
.mmt-tabs-label {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.tabs-active {
|
||||
.mmt-tabs-active {
|
||||
--depth: 1;
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
function bindTabs(tabsId) {
|
||||
bindTooltipsWithDelegation(tabsId)
|
||||
function getTabContentBoundaries(tabsId) {
|
||||
const tabsContainer = document.getElementById(tabsId)
|
||||
console.debug("tabsContainer", tabsContainer)
|
||||
const contentDiv = tabsContainer.querySelector('.mmt-tabs-content')
|
||||
|
||||
const boundaries = contentDiv.getBoundingClientRect()
|
||||
return {
|
||||
width: boundaries.width,
|
||||
height: boundaries.height
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import dataclasses
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from assets.icons import icon_dismiss_regular
|
||||
from components.BaseComponent import BaseComponent
|
||||
from components.tabs.constants import MY_TABS_INSTANCE_ID, Routes, ROUTE_ROOT
|
||||
from components_helpers import mk_ellipsis, mk_tooltip_container
|
||||
from components_helpers import mk_ellipsis
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.utils import get_unique_id
|
||||
|
||||
@@ -151,12 +150,9 @@ class MyTabs(BaseComponent):
|
||||
return self.render(oob=True)
|
||||
|
||||
def __ft__(self):
|
||||
return mk_tooltip_container(self._id), self.render(), Script(f"bindTabs('{self._id}')")
|
||||
return self.render()
|
||||
|
||||
def render(self, oob=False):
|
||||
if not self.tabs:
|
||||
return Div(id=self._id, hx_swap_oob="true" if oob else None)
|
||||
|
||||
active_content = self.get_active_tab_content()
|
||||
if hasattr(active_content, "on_htmx_after_settle"):
|
||||
extra_params = {"hx-on::after-settle": active_content.on_htmx_after_settle()}
|
||||
@@ -164,9 +160,9 @@ class MyTabs(BaseComponent):
|
||||
extra_params = {}
|
||||
|
||||
return Div(
|
||||
*[self._mk_tab(tab) for tab in self.tabs], # headers
|
||||
Div(active_content, cls="tabs-content"),
|
||||
cls="tabs",
|
||||
Div(*[self._mk_tab(tab) for tab in self.tabs], cls="mmt-tabs-header"), # headers
|
||||
Div(active_content, cls="mmt-tabs-content"),
|
||||
cls="mmt-tabs",
|
||||
id=self._id,
|
||||
hx_swap_oob="true" if oob else None,
|
||||
**extra_params,
|
||||
@@ -174,9 +170,9 @@ class MyTabs(BaseComponent):
|
||||
|
||||
def _mk_tab(self, tab: Tab):
|
||||
return Span(
|
||||
Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="tabs-label truncate"),
|
||||
Label(mk_ellipsis(tab.title), hx_post=f"{ROUTE_ROOT}{Routes.SelectTab}", cls="mmt-tabs-label truncate"),
|
||||
Div(icon_dismiss_regular, cls="icon-16 ml-2", hx_post=f"{ROUTE_ROOT}{Routes.RemoveTab}"),
|
||||
cls=f"tabs-tab {'tabs-active' if tab.active else ''}",
|
||||
cls=f"mmt-tabs-tab {'mmt-tabs-active' if tab.active else ''}",
|
||||
hx_vals=f'{{"_id": "{self._id}", "tab_id":"{tab.id}"}}',
|
||||
hx_target=f"#{self._id}",
|
||||
hx_swap="outerHTML",
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from components.themecontroller.constants import Routes
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.instance_manager import InstanceManager, debug_session
|
||||
|
||||
logger = logging.getLogger("ThemeControllerApp")
|
||||
|
||||
@@ -12,6 +12,6 @@ theme_controller_app, rt = fast_app()
|
||||
|
||||
@rt(Routes.ChangeTheme)
|
||||
def post(session, _id: str, theme: str):
|
||||
logger.debug(f"Entering {Routes.ChangeTheme} with args {session=}, {theme=}")
|
||||
logger.debug(f"Entering {Routes.ChangeTheme} with args {debug_session(session)}, {theme=}")
|
||||
instance = InstanceManager.get(session, _id)
|
||||
instance.change_theme(theme)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fasthtml.svg import *
|
||||
|
||||
from components.themecontroller.constants import ROUTE_ROOT, Routes
|
||||
from components.themecontroller.settings import THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings
|
||||
from core.settings_management import SettingsManager
|
||||
from core.settings_management import SettingsManager, GenericDbManager
|
||||
|
||||
logger = logging.getLogger("ThemeController")
|
||||
|
||||
@@ -14,12 +14,7 @@ logger = logging.getLogger("ThemeController")
|
||||
class ThemeController:
|
||||
def __init__(self, session, settings_manager: SettingsManager, /, _id=None):
|
||||
self._id = _id or uuid.uuid4().hex
|
||||
self.session = session
|
||||
self.settings_manager = settings_manager
|
||||
|
||||
self.settings = self.settings_manager.get(session,
|
||||
THEME_CONTROLLER_SETTINGS_ENTRY,
|
||||
default=ThemeControllerSettings())
|
||||
self.db = GenericDbManager(session, settings_manager, THEME_CONTROLLER_SETTINGS_ENTRY, ThemeControllerSettings)
|
||||
|
||||
def __ft__(self):
|
||||
return Div(
|
||||
@@ -46,7 +41,7 @@ class ThemeController:
|
||||
Li(
|
||||
Input(type='radio', name='theme', aria_label='Default', value='default',
|
||||
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
|
||||
checked=self.settings.theme is None or self.settings.theme == 'default',
|
||||
checked=self.db.theme is None or self.db.theme == 'default',
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
|
||||
hx_vals=f'{{"_id": "{self._id}"}}',
|
||||
)
|
||||
@@ -54,7 +49,7 @@ class ThemeController:
|
||||
Li(
|
||||
Input(type='radio', name='theme', aria_label='Dark', value='dark',
|
||||
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
|
||||
checked=self.settings.theme == 'dark',
|
||||
checked=self.db.theme == 'dark',
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
|
||||
hx_vals=f'{{"_id": "{self._id}"}}',
|
||||
)
|
||||
@@ -62,7 +57,7 @@ class ThemeController:
|
||||
Li(
|
||||
Input(type='radio', name='theme', aria_label='Light', value='light',
|
||||
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
|
||||
checked=self.settings.theme == 'light',
|
||||
checked=self.db.theme == 'light',
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
|
||||
hx_vals=f'{{"_id": "{self._id}"}}',
|
||||
)
|
||||
@@ -70,15 +65,7 @@ class ThemeController:
|
||||
Li(
|
||||
Input(type='radio', name='theme', aria_label='Cupcake', value='cupcake',
|
||||
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
|
||||
checked=self.settings.theme == 'cupcake',
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
|
||||
hx_vals=f'{{"_id": "{self._id}"}}',
|
||||
)
|
||||
),
|
||||
Li(
|
||||
Input(type='radio', name='theme', aria_label='Lofi', value='lofi',
|
||||
cls='theme-controller w-full btn btn-sm btn-block btn-ghost justify-start',
|
||||
checked=self.settings.theme == 'lofi',
|
||||
checked=self.db.theme == 'cupcake',
|
||||
hx_post=f"{ROUTE_ROOT}{Routes.ChangeTheme}",
|
||||
hx_vals=f'{{"_id": "{self._id}"}}',
|
||||
)
|
||||
@@ -91,5 +78,4 @@ class ThemeController:
|
||||
|
||||
def change_theme(self, theme):
|
||||
logger.debug(f"change_theme - Changing theme to '{theme}'.")
|
||||
self.settings.theme = theme
|
||||
self.settings_manager.put(self.session, THEME_CONTROLLER_SETTINGS_ENTRY, self.settings)
|
||||
self.db.theme = theme
|
||||
|
||||
@@ -2,7 +2,7 @@ import dataclasses
|
||||
|
||||
from core.settings_objects import BaseSettingObj
|
||||
|
||||
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeControllerSettings"
|
||||
THEME_CONTROLLER_SETTINGS_ENTRY = "ThemeController"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
216
src/main.py
216
src/main.py
@@ -1,42 +1,26 @@
|
||||
# global layout
|
||||
import logging.config
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from fasthtml.common import *
|
||||
|
||||
from assets.css import my_managing_tools_style
|
||||
from auth.auth_manager import AuthManager
|
||||
from components.DrawerLayoutOld import DrawerLayout as DrawerLayoutOld
|
||||
from components.DrawerLayoutOld import Page
|
||||
from components.addstuff.AddStuffApp import add_stuff_app
|
||||
from components.addstuff.constants import ROUTE_ROOT as ADD_STUFF_ROUTE_ROOT
|
||||
from components.datagrid.DataGrid import DATAGRID_PATH, datagrid_app
|
||||
from components.datagrid_new.DataGridApp import datagrid_new_app
|
||||
from components.datagrid_new.constants import ROUTE_ROOT as DATAGRID_NEW_ROUTE_ROOT
|
||||
from components.debugger.DebuggerApp import debugger_app
|
||||
from components.debugger.constants import ROUTE_ROOT as DEBUGGER_ROUTE_ROOT
|
||||
from components.drawerlayout.DrawerLayoutApp import drawer_layout_app
|
||||
from components.drawerlayout.components import DrawerLayout
|
||||
from components.drawerlayout.components.DrawerLayout import DrawerLayout
|
||||
from components.drawerlayout.constants import ROUTE_ROOT as DRAWER_LAYOUT_ROUTE_ROOT
|
||||
from components.form.FormApp import form_app
|
||||
from components.form.constants import ROUTE_ROOT as FORM_ROUTE_ROOT
|
||||
from components.login.LoginApp import login_app
|
||||
from components.login.components.Login import Login
|
||||
from components.login.constants import ROUTE_ROOT as LOGIN_ROUTE_ROOT
|
||||
from components.login.constants import Routes as LoginRoutes
|
||||
from components.page_layout_new import page_layout_new, page_layout_lite
|
||||
from components.register.RegisterApp import register_app
|
||||
from components.register.components.Register import Register
|
||||
from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
|
||||
from components.register.constants import Routes as RegisterRoutes
|
||||
from components.tabs.TabsApp import tabs_app
|
||||
from components.tabs.constants import ROUTE_ROOT as TABS_ROUTE_ROOT
|
||||
from components.themecontroller.ThemeControllerApp import theme_controller_app
|
||||
from components.themecontroller.constants import ROUTE_ROOT as THEME_CONTROLLER_ROUTE_ROOT
|
||||
from constants import Routes
|
||||
from core.dbengine import DbException
|
||||
from core.instance_manager import NO_SESSION, NOT_LOGGED, InstanceManager
|
||||
from core.instance_manager import InstanceManager
|
||||
from core.settings_management import SettingsManager
|
||||
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
|
||||
from pages.another_grid import get_datagrid2
|
||||
@@ -52,59 +36,148 @@ with open('logging.yaml', 'r') as f:
|
||||
# At the top of your script or module
|
||||
logging.config.dictConfig(config)
|
||||
|
||||
logger = logging.getLogger("MainApp")
|
||||
|
||||
# daisy_ui_links_v4 = (
|
||||
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
|
||||
# Script(src="https://cdn.tailwindcss.com"),
|
||||
# )
|
||||
|
||||
daisy_ui_links_v4 = (
|
||||
Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
|
||||
Script(src="./assets/tailwindcss.js"),
|
||||
)
|
||||
|
||||
daisy_ui_links = (
|
||||
links = [
|
||||
# start with daisyui
|
||||
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5", rel="stylesheet", type="text/css"),
|
||||
Link(href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css", rel="stylesheet", type="text/css"),
|
||||
Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"),
|
||||
)
|
||||
|
||||
main_links = (Script(src="./assets/main.js"),
|
||||
Link(rel="stylesheet", href="./assets/main.css", type="text/css"),)
|
||||
|
||||
drawer_layout = (Script(src="./components/drawerlayout/assets/DrawerLayout.js"),
|
||||
Link(rel="stylesheet", href="./components/drawerlayout/assets/DrawerLayout.css"),)
|
||||
|
||||
datagridOld = (Script(src="./components/datagrid/DataGrid.js"),
|
||||
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"))
|
||||
|
||||
drw_layout_old = (Script(src="./assets/DrawerLayout.js", defer=True),
|
||||
Link(rel="stylesheet", href="./assets/DrawerLayout.css"))
|
||||
|
||||
datagrid = (Script(src="./components/datagrid_new/assets/Datagrid.js"),
|
||||
Link(rel="stylesheet", href="./components/datagrid_new/assets/Datagrid.css"))
|
||||
|
||||
addstuff = (Script(src="./components/addstuff/assets/addstuff.js"),)
|
||||
|
||||
tabs = (Script(src="./components/tabs/assets/tabs.js"),
|
||||
Link(rel="stylesheet", href="./components/tabs/assets/tabs.css"),)
|
||||
|
||||
debugger = (Script(type="module", src="./components/debugger/assets/Debugger.js"),)
|
||||
|
||||
routes = (
|
||||
Mount(LOGIN_ROUTE_ROOT, login_app, name="login"),
|
||||
Mount(REGISTER_ROUTE_ROOT, register_app, name="register"),
|
||||
Mount(THEME_CONTROLLER_ROUTE_ROOT, theme_controller_app, name="theme_controller"),
|
||||
Mount(DRAWER_LAYOUT_ROUTE_ROOT, drawer_layout_app, name="main"),
|
||||
Mount(ADD_STUFF_ROUTE_ROOT, add_stuff_app, name="add_stuff"),
|
||||
Mount(TABS_ROUTE_ROOT, tabs_app, name="tabs"),
|
||||
Mount(FORM_ROUTE_ROOT, form_app, name="form"),
|
||||
Mount(DATAGRID_NEW_ROUTE_ROOT, datagrid_new_app, name="datagrid_new"),
|
||||
Mount(DEBUGGER_ROUTE_ROOT, debugger_app, name="debugger"),
|
||||
|
||||
# Old drawer layout
|
||||
Script(src="./assets/DrawerLayout.js", defer=True),
|
||||
Link(rel="stylesheet", href="./assets/DrawerLayout.css"),
|
||||
|
||||
# Old datagrid
|
||||
Script(src="./components/datagrid/DataGrid.js"),
|
||||
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
|
||||
|
||||
# add main
|
||||
Script(src="./assets/main.js"),
|
||||
Link(rel="stylesheet", href="./assets/main.css", type="text/css")
|
||||
]
|
||||
|
||||
routes = []
|
||||
|
||||
|
||||
def register_component(name, path, app_module_name):
|
||||
def _get_fast_app(_module):
|
||||
if app_module_name is None:
|
||||
logger.debug(f" No app module defined for {_module.__name__}. Skipping fast app lookup.")
|
||||
return None
|
||||
|
||||
for var_name in dir(_module):
|
||||
if var_name.endswith('App'):
|
||||
component_module = getattr(_module, var_name)
|
||||
logger.debug(f" Found component module {component_module.__name__}")
|
||||
for sub_var_name in dir(component_module):
|
||||
if sub_var_name.endswith("_app"):
|
||||
res = getattr(component_module, sub_var_name)
|
||||
if isinstance(res, FastHTML):
|
||||
logger.debug(f" Found FastHTML app component {sub_var_name}")
|
||||
return res
|
||||
|
||||
raise ValueError(f" Cannot find app for component {name}")
|
||||
|
||||
def _get_route_root(_module):
|
||||
if app_module_name is None:
|
||||
logger.debug(f" No app module defined for {_module.__name__}. Skipping route root lookup.")
|
||||
return None
|
||||
|
||||
constants_module = getattr(_module, "constants")
|
||||
return getattr(constants_module, "ROUTE_ROOT")
|
||||
|
||||
def _get_links(_module):
|
||||
# Get module file path and construct assets directory path
|
||||
module_path = os.path.dirname(_module.__file__)
|
||||
assets_path = os.path.join(module_path, 'assets')
|
||||
|
||||
if not os.path.exists(assets_path):
|
||||
logger.debug(f" No 'assets' folder not found in module {_module.__name__}")
|
||||
return None
|
||||
|
||||
# Find all CSS files in assets directory
|
||||
files = []
|
||||
for file in os.listdir(assets_path):
|
||||
if file.endswith('.css'):
|
||||
file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
|
||||
files.append(Link(rel="stylesheet", href=file_path, type="text/css"))
|
||||
logger.debug(f" Found CSS file {file_path}")
|
||||
elif file.endswith('.js'):
|
||||
file_path = f'./{os.path.relpath(os.path.join(assets_path, file))}'
|
||||
files.append(Script(src=file_path))
|
||||
logger.debug(f" Found JS file {file_path}")
|
||||
|
||||
return files if files else None
|
||||
|
||||
try:
|
||||
# Import the module dynamically
|
||||
logger.debug(f"register_component {name=} {path=} {app_module_name=}")
|
||||
module = __import__(path, fromlist=['*', app_module_name] if app_module_name else ['*'])
|
||||
|
||||
component_app = _get_fast_app(module)
|
||||
component_route_root = _get_route_root(module)
|
||||
component_links = _get_links(module)
|
||||
|
||||
if component_app is not None:
|
||||
routes.append(Mount(component_route_root, component_app, name=name))
|
||||
|
||||
if component_links is not None:
|
||||
links.extend(component_links)
|
||||
|
||||
except ImportError:
|
||||
logging.error(f"Could not import module {path}")
|
||||
|
||||
|
||||
def query_mistral(prompt):
|
||||
"""Send a query to the Mistral model via Ollama API"""
|
||||
ollama_host = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
|
||||
response = requests.post(
|
||||
f"{ollama_host}/api/generate",
|
||||
json={
|
||||
"model": "mistral",
|
||||
"prompt": prompt,
|
||||
"stream": False
|
||||
}
|
||||
)
|
||||
return response.json()
|
||||
|
||||
|
||||
register_component("login", "components.login", "LoginApp")
|
||||
register_component("register", "components.register", "RegisterApp")
|
||||
register_component("theme_controller", "components.themecontroller", "ThemeControllerApp")
|
||||
register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
|
||||
register_component("tabs", "components.tabs", "TabsApp") # before repositories
|
||||
register_component("repositories", "components.repositories", "RepositoriesApp")
|
||||
register_component("add_stuff", "components.addstuff", None)
|
||||
register_component("form", "components.form", "FormApp")
|
||||
register_component("datagrid_new", "components.datagrid_new", "DataGridApp")
|
||||
register_component("debugger", "components.debugger", "DebuggerApp")
|
||||
|
||||
routes.extend([
|
||||
# old stuffs
|
||||
Mount(f"/{BASIC_TEST_PATH}", basic_test_app, name="basic test"),
|
||||
Mount(f"/{DATAGRID_PATH}", datagrid_app, name="datagrid"),
|
||||
Mount(f"/{IMPORT_SETTINGS_PATH}", import_settings_app, name="import_settings"),
|
||||
])
|
||||
|
||||
old_links = (
|
||||
# daisy_ui_links_v4
|
||||
Link(href="./assets/daisyui-4.12.10-full-min.css", rel="stylesheet", type="text/css"),
|
||||
Script(src="./assets/tailwindcss.js"),
|
||||
|
||||
# datagridOld
|
||||
Script(src="./components/datagrid/DataGrid.js"),
|
||||
Link(rel="stylesheet", href="./components/datagrid/DataGrid.css"),
|
||||
|
||||
# drw_layout_old
|
||||
Script(src="./assets/DrawerLayout.js", defer=True),
|
||||
Link(rel="stylesheet", href="./assets/DrawerLayout.css")
|
||||
)
|
||||
|
||||
|
||||
@@ -133,18 +206,14 @@ bware = Beforeware(before, skip=[r'/favicon\.ico',
|
||||
|
||||
app, rt = fast_app(
|
||||
before=bware,
|
||||
hdrs=(daisy_ui_links,
|
||||
main_links, my_managing_tools_style,
|
||||
drawer_layout, addstuff,
|
||||
tabs, debugger, datagrid),
|
||||
hdrs=tuple(links),
|
||||
live=True,
|
||||
routes=routes,
|
||||
routes=tuple(routes),
|
||||
debug=True,
|
||||
pico=False,
|
||||
)
|
||||
|
||||
settings_manager = SettingsManager()
|
||||
settings_manager.init_user(NO_SESSION, NOT_LOGGED)
|
||||
|
||||
import_settings = AdminImportSettings(settings_manager, None)
|
||||
pages = [
|
||||
@@ -181,7 +250,7 @@ def get(session):
|
||||
@rt("/test")
|
||||
def get(session):
|
||||
return (Title("Another Project Management"),
|
||||
datagridOld, drw_layout_old, daisy_ui_links_v4,
|
||||
old_links,
|
||||
Input(type='checkbox', value='light', cls='toggle theme-controller'),
|
||||
DrawerLayoutOld(pages),)
|
||||
|
||||
@@ -219,4 +288,19 @@ def get(session):
|
||||
return Titled("I like toast")
|
||||
|
||||
|
||||
serve(port=5001)
|
||||
if __name__ == "__main__":
|
||||
# Start your application
|
||||
print("Application starting...")
|
||||
|
||||
print("Checking if Mistral model is available...")
|
||||
try:
|
||||
requests.post(
|
||||
f"{os.environ.get('OLLAMA_HOST', 'http://localhost:11434')}/api/pull",
|
||||
json={"name": "mistral"}
|
||||
)
|
||||
print("Mistral model is ready")
|
||||
except Exception as e:
|
||||
print(f"Error pulling Mistral model: {e}")
|
||||
|
||||
|
||||
serve(port=5001)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
107
tests/helpers.py
107
tests/helpers.py
@@ -8,7 +8,7 @@ import pandas as pd
|
||||
from bs4 import BeautifulSoup
|
||||
from fastcore.basics import NotStr
|
||||
from fastcore.xml import to_xml
|
||||
from fasthtml.components import html2ft, Div
|
||||
from fasthtml.components import html2ft, Div, Span
|
||||
|
||||
pattern = r"""(?P<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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
@@ -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
159
tests/test_jsonviewer.py
Normal 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
|
||||
@@ -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']")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user