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