First Working version. I can add table

This commit is contained in:
2025-05-10 16:55:52 +02:00
parent b708ef2c46
commit 2daff83e67
157 changed files with 17282 additions and 12 deletions

5
.gitignore vendored
View File

@@ -6,6 +6,11 @@ app.egg-info
htmlcov
.cache
.venv
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
.sesskey
tools.db
.mytools_db
# Created by .ignore support plugin (hsz.mobi)
### Python template

View File

@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (MyManagingTools)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

24
Makefile Normal file
View File

@@ -0,0 +1,24 @@
.PHONY: test
test:
pytest
coverage:
coverage run --source=src -m pytest
coverage html
clean:
rm -rf build
rm -rf htmlcov
rm -rf .coverage
rm -rf tests/*.csv
rm -rf tests/*:Zone.Identifier
rm -rf tests/TestDBEngineRoot
rm -rf tests/settings_from_unit_testing.json
rm -rf Untitled*.ipynb
rm -rf .ipynb_checkpoints
rm -rf src/tools.db
find . -name '.sesskey' -exec rm -rf {} +
find . -name '.pytest_cache' -exec rm -rf {} +
find . -name '__pycache__' -exec rm -rf {} +
find . -name 'debug.txt' -exec rm -rf {}

View File

@@ -29,3 +29,6 @@ uvicorn==0.30.6
uvloop==0.20.0
watchfiles==0.24.0
websockets==13.1
pandas~=2.2.3
numpy~=2.1.1

View File

@@ -0,0 +1,28 @@
.drawer-layout {
display: flex;
margin: 0;
}
.sidebar {
width: 200px;
min-width : 200px;
transition: width 0.4s ease;
}
.sidebar.collapsed {
overflow: hidden;
width: 0;
min-width : 0;
padding: 0;
}
.main {
flex-grow: 1;
padding: 20px;
}
.toggle-button {
margin-bottom: 20px;
cursor: pointer;
padding: 10px 15px;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
}

View File

@@ -0,0 +1,98 @@
// ===============================================================
// SEND INPUT BEFORE AJAX
// ===============================================================
// Select all elements with id starting with 'navItem-' within the #sidebar div
var navItems = document.querySelectorAll('[name="sidebar"] [id^="navItem-"]');
navItems.forEach(function (navItem) {
navItem.addEventListener("htmx:confirm", function (event) {
event.preventDefault();
// Get the page id
var layout_id = navItem.getAttribute("hx-target");
var pageDiv = document.querySelector(layout_id);
var current_page = pageDiv.querySelector('[name="current_page"]');
var current_page_id = current_page.id;
if (pageDiv) {
var elementsWithId = current_page.querySelectorAll("[id]");
var elementsInfo = [];
elementsWithId.forEach(function (element) {
// Skip elements with id starting with 'datagrid-'
if (element.id.startsWith("datagrid-")) {
return;
}
let value = "";
let method = "";
switch (element.tagName) {
case "INPUT":
if (element.type === "checkbox") {
value = element.checked; // For checkboxes
method = "checked";
} else if (element.type === "radio") {
value = element.checked;
method = "checked";
} else if (element.type === "file") {
value = element.files.length > 0 ? Array.from(element.files).map((file) => file.name) : []; // Array of file names
method = "files";
} else {
value = element.value; // For other input types
method = "value";
}
break;
case "TEXTAREA":
value = element.value; // For textareas
method = "value";
break;
case "SELECT":
value = element.multiple
? Array.from(element.selectedOptions).map((option) => option.value) // Multiple selections
: element.value; // Single selection
method = "value";
break;
default:
if (element.isContentEditable) {
value = element.innerText.trim(); // For contenteditable elements
method = "innerText";
} else {
value = element.textContent; // For other elements
method = "textContent";
}
break;
}
elementsInfo.push({
id: element.id,
type: element.tagName.toLowerCase(), // Element type in lowercase
value: value,
method: method,
});
});
var result = {
dl_id: layout_id.replace("#page_", ""),
page_id: current_page_id,
state: elementsInfo,
};
// Post the elementInfo to the specified URL
fetch("/pages/store_state", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(result),
})
.then((response) => {
event.detail.issueRequest(true);
})
.catch((error) => {
console.error("Error:", error); // Handle errors if any occur
event.detail.issueRequest(true);
});
}
});
});

0
src/assets/__init__.py Normal file
View File

62
src/assets/css.py Normal file
View File

@@ -0,0 +1,62 @@
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;
}
""")

20
src/assets/daisyui-4.12.10-full-min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
src/assets/fragments.py Normal file
View File

@@ -0,0 +1,6 @@
# <input type="text" name="q"
# hx-get="/trigger_delay"
# hx-trigger="keyup changed delay:500ms"
# hx-target="#search-results"
# placeholder="Search..."
# >

12
src/assets/icons.py Normal file
View File

@@ -0,0 +1,12 @@
# fluent DismissCircle24Regular
from fastcore.basics import NotStr
# DismissCircle20Regular
icon_dismiss_regular = NotStr(
"""<svg name="close" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M10 2a8 8 0 1 1 0 16a8 8 0 0 1 0-16zm0 1a7 7 0 1 0 0 14a7 7 0 0 0 0-14zM7.81 7.114l.069.058L10 9.292l2.121-2.12a.5.5 0 0 1 .638-.058l.07.058a.5.5 0 0 1 .057.637l-.058.07L10.708 10l2.12 2.121a.5.5 0 0 1 .058.638l-.058.07a.5.5 0 0 1-.637.057l-.07-.058L10 10.708l-2.121 2.12a.5.5 0 0 1-.638.058l-.07-.058a.5.5 0 0 1-.057-.637l.058-.07L9.292 10l-2.12-2.121a.5.5 0 0 1-.058-.638l.058-.07a.5.5 0 0 1 .637-.057z" fill="currentColor">
</path>
</g>
</svg>"""
)

25
src/assets/main.css Normal file
View File

@@ -0,0 +1,25 @@
:root {
--theme-controller-zindex: 1000;
--datagrid-sidebar-zindex: 900;
--datagrid-scrollbars-zindex: 800;
}
.mmt-tooltip-container {
background: var(--color-base-200);
padding: 5px 10px;
border-radius: 4px;
pointer-events: none; /* Prevent interfering with mouse events */
font-size: 12px;
white-space: nowrap;
opacity: 0; /* Default to invisible */
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 */
}
.mmt-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
}

51
src/assets/main.js Normal file
View File

@@ -0,0 +1,51 @@
function bindTooltipsWithDelegation(elementId) {
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");
return;
}
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
const cell = event.target.closest("div[data-tooltip]");
if (!cell) return;
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains("mmt-tooltip");
if (isOverflowing || forceShow) {
const tooltipText = cell.getAttribute("data-tooltip");
if (tooltipText) {
const rect = cell.getBoundingClientRect();
const tooltipRect = tooltipContainer.getBoundingClientRect();
let top = rect.top - 30; // Above the cell
let left = rect.left;
// Adjust tooltip position to prevent it from going off-screen
if (top < 0) top = rect.bottom + 5; // Move below if no space above
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
}
// Apply styles for tooltip positioning
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]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Use capture phase for better delegation if needed
}

View File

@@ -0,0 +1,16 @@
// THis does not work as module is not defined !!!
module.exports = {
//...
daisyui: {
themes: [
{
light: {
...require("daisyui/src/theming/themes")["light"],
"primary": "blue",
"secondary": "teal",
"accent": "#37cdbe",
},
},
],
},
}

83
src/assets/tailwindcss.js Normal file

File diff suppressed because one or more lines are too long

0
src/auth/__init__.py Normal file
View File

123
src/auth/auth_manager.py Normal file
View File

@@ -0,0 +1,123 @@
from typing import Optional, Dict, Any, Tuple
from core.user_dao import UserDAO
class AuthManager:
"""
Authentication manager that handles user sessions and permissions.
"""
@staticmethod
def login_user(session, user_data: Dict[str, Any]) -> None:
"""
Log in a user by setting session data.
Args:
session: The session object
user_data: User data to store in session
"""
# Store minimal user data in session
session["user_id"] = user_data["id"]
session["username"] = user_data["username"]
session["user_email"] = user_data["email"]
session["is_admin"] = bool(user_data["is_admin"])
@staticmethod
def logout_user(session) -> None:
"""
Log out a user by clearing session data.
Args:
session: The session object
"""
# Clear all user-related session data
session.pop("user_id", None)
session.pop("username", None)
session.pop("user_email", None)
session.pop("is_admin", None)
@staticmethod
def get_current_user(session) -> Optional[Dict[str, Any]]:
"""
Get the currently logged-in user from session.
Args:
session: The session object
Returns:
Dict or None: User data if logged in, None otherwise
"""
user_id = session.get("user_id")
if not user_id:
return None
# Get full user data from database
return UserDAO.get_user_by_id(user_id)
@staticmethod
def is_authenticated(session) -> bool:
"""
Check if a user is authenticated.
Args:
session: The session object
Returns:
bool: True if authenticated, False otherwise
"""
return "user_id" in session
@staticmethod
def is_admin(session) -> bool:
"""
Check if the current user is an admin.
Args:
session: The session object
Returns:
bool: True if admin, False otherwise
"""
return session.get("is_admin", False)
@staticmethod
def require_auth(session) -> Tuple[bool, Optional[str]]:
"""
Check if authentication is required.
Args:
session: The session object
Returns:
Tuple[bool, Optional[str]]: (is_authorized, redirect_url)
"""
if not AuthManager.is_authenticated(session):
return False, "/login"
return True, None
@staticmethod
def require_admin(session) -> Tuple[bool, Optional[str]]:
"""
Check if admin authentication is required.
Args:
session: The session object
Returns:
Tuple[bool, Optional[str]]: (is_authorized, redirect_url)
"""
if not AuthManager.is_authenticated(session):
return False, "/login"
if not AuthManager.is_admin(session):
return False, "/"
return True, None
@staticmethod
def get_current_user_id(session):
if not AuthManager.is_authenticated(session):
return None
return session["user_id"]

93
src/auth/email_auth.py Normal file
View File

@@ -0,0 +1,93 @@
import re
from typing import Any
from core.user_dao import UserDAO
class EmailAuth:
"""
Handles email/password authentication.
"""
@staticmethod
def validate_registration(username: str, email: str, password: str, confirm_password: str) -> tuple[bool, str]:
"""
Validate registration input.
Args:
username: The username
email: The email address
password: The password
confirm_password: Password confirmation
Returns:
Tuple[bool, str]: (is_valid, error_message)
"""
# Check username length
if len(username) < 3 or len(username) > 30:
return False, "Username must be between 3 and 30 characters"
# Check username format (letters, numbers, underscores, hyphens)
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
return False, "Username can only contain letters, numbers, underscores, and hyphens"
# Check email format
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
return False, "Invalid email format"
# Check password length
if len(password) < 8:
return False, "Password must be at least 8 characters long"
# Check password strength (at least one uppercase, one lowercase, one digit)
if not (re.search(r'[A-Z]', password) and re.search(r'[a-z]', password) and re.search(r'[0-9]', password)):
return False, "Password must contain at least one uppercase letter, one lowercase letter, and one digit"
# Check password match
if password != confirm_password:
return False, "Passwords do not match"
return True, ""
@staticmethod
def register_user(username: str, email: str, password: str) -> tuple[bool, str, int | None]:
"""
Register a new user with email and password.
Args:
username: The username
email: The email address
password: The password
Returns:
Tuple[bool, str, Optional[int]]: (success, message, user_id)
"""
# Create user in database
user_id = UserDAO.create_user(username=username, email=email, password=password)
if user_id == 0:
return False, "Username or email already exists", None
return True, "Registration successful", user_id
@staticmethod
def authenticate(email: str, password: str) -> tuple[bool, str, dict[str, Any] | None]:
"""
Authenticate a user with email and password.
Args:
email: The email address
password: The password
Returns:
Tuple[bool, str, Optional[Dict]]: (success, message, user_data)
"""
if not email or not password:
return False, "Email and password are required", None
user_data = UserDAO.authenticate_email(email, password)
if not user_data:
return False, "Invalid email or password", None
return True, "Authentication successful", user_data

View File

@@ -0,0 +1,27 @@
class BaseComponent:
"""
Base class for all components that need to have a session and an id
"""
def __init__(self, session, _id=None, **kwargs):
self._session = session
self._id = _id or self.create_component_id(session)
def get_id(self):
return self._id
def __repr__(self):
return self._id
def __eq__(self, other):
if type(other) is type(self):
return self._id == other.get_id()
else:
return False
def __hash__(self):
return hash(self._id)
@staticmethod
def create_component_id(session):
pass

View File

@@ -0,0 +1,123 @@
import dataclasses
import inspect
import logging
import uuid
from fasthtml.common import *
from fasthtml.starlette import HTTPException
from core.utils import make_html_id, update_elements
DRAWER_LAYOUT_PATH = "pages"
ID_PREFIX = "drawerlayout"
drawer_layout_app, rt = fast_app()
logger = logging.getLogger(__name__)
_instances = {}
_states = {}
@dataclasses.dataclass
class Page:
title: str
callback: object
id: str = None
def get_content(self):
if self.callback is None:
return None
if inspect.isfunction(self.callback):
return self.callback()
if hasattr(self.callback, "__ft__"):
return self.callback.__ft__()
raise NotImplemented("Not support page type")
class DrawerLayout:
def __new__(cls, *args, **kwargs):
id_to_use = f"{ID_PREFIX}-{make_html_id(kwargs.get('id', None))}"
if id_to_use in _instances:
return _instances[id_to_use]
return super().__new__(cls)
def __init__(self, pages, default_page=None, /, id=None):
if not hasattr(self, "_initialized"):
self._id = f"{ID_PREFIX}-{make_html_id(id) if id else uuid.uuid4().hex}"
_instances[self._id] = self
self._pages = pages or []
self.default_page = default_page
self._initialized = True
def __ft__(self):
default_page = self._pages[0].get_content() if self._pages[0] else "Right"
wrapped = Div(default_page, name="current_page", id=self._pages[0].id)
return Div(
Div(
Ul(
*[Li(A(p.title,
hx_get=f"/{DRAWER_LAYOUT_PATH}/?dl_id={self._id}&page_id={p.id}",
hx_target=f"#page_{self._id}",
id=f"navItem-{p.id}"))
for p in self._pages],
cls="menu"),
id=f"sidebar_{self._id}",
cls="sidebar",
name="sidebar"
),
Div(
Button('Toggle Sidebar',
id='toggleButton',
cls='toggle-button',
onclick=f"document.getElementById('sidebar_{self._id}').classList.toggle('collapsed')"), #
Div(wrapped, id=f"page_{self._id}", name="page"),
cls='main',
),
cls="drawer-layout"
)
def get_page(self, page_id):
page = list(filter(lambda p: p.id == page_id, self._pages))
if not page:
raise HTTPException(status_code=404, detail="Page not found")
return page[0]
def restore_state(self, page_id: str, obj):
key = (self._id, page_id)
if key in _states:
state = _states[key]
logger.debug(f"Found state :{state}")
html = obj.__ft__() if hasattr(obj, "__ft__") \
else obj.__html__() if hasattr(obj, "__html") \
else obj
obj = update_elements(html, _states[key]["state"])
return Div(obj, name="current_page", id=page_id)
@rt("/")
def get(dl_id: str, page_id: str):
instance = _instances[dl_id]
page = instance.get_page(page_id)
if page.callback is not None:
return instance.restore_state(page_id, page.get_content())
return Div(Span(f"'{page_id}' is not implemented !"), role="alert", cls="alert alert-error")
@rt("/store_state")
def post(state: dict):
"""
Record the state of a page
:param state:
:return:
"""
key = (state["dl_id"], state["page_id"])
_states[key] = state
logger.debug(f"Stored state : {state} with {key=}")

View File

View File

@@ -0,0 +1,39 @@
import logging
from fasthtml.fastapp import fast_app
from components.addstuff.components.Repositories import Repositories
from components.addstuff.constants import Routes
from core.instance_manager import InstanceManager, debug_session
logger = logging.getLogger("AddStuffApp")
add_stuff_app, rt = fast_app()
@rt(Routes.AddRepository)
def get(session):
repositories_instance = InstanceManager.get(session, Repositories.create_component_id(session))
return repositories_instance.request_new_repository()
@rt(Routes.AddRepository)
def post(session, _id: str, tab_id: str, form_id: str, repository: str, table: str):
logger.debug(
f"Entering {Routes.AddRepository} with args {debug_session(session)}, {_id=}, {tab_id=}, {form_id=}, {repository=}, {table=}")
instance = InstanceManager.get(session, _id) # Repository
return instance.add_new_repository(tab_id, form_id, repository, table)
@rt(Routes.SelectRepository)
def put(session, _id: str, repository: str):
logger.debug(f"Entering {Routes.SelectRepository} with args {debug_session(session)}, {_id=}, {repository=}")
instance = InstanceManager.get(session, _id)
return instance.select_repository(repository)
@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=}")
instance = InstanceManager.get(session, _id)
return instance.show_table(repository, table)

View File

View File

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

View File

@@ -0,0 +1,21 @@
from fastcore.basics import NotStr
# Fluent Database20Regular
icon_database = NotStr("""
<svg name="database" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M4 5c0-1.007.875-1.755 1.904-2.223C6.978 2.289 8.427 2 10 2s3.022.289 4.096.777C15.125 3.245 16 3.993 16 5v10c0 1.007-.875 1.755-1.904 2.223C13.022 17.71 11.573 18 10 18s-3.022-.289-4.096-.777C4.875 16.755 4 16.007 4 15V5zm1 0c0 .374.356.875 1.318 1.313C7.234 6.729 8.536 7 10 7s2.766-.27 3.682-.687C14.644 5.875 15 5.373 15 5c0-.374-.356-.875-1.318-1.313C12.766 3.271 11.464 3 10 3s-2.766.27-3.682.687C5.356 4.125 5 4.627 5 5zm10 1.698a4.92 4.92 0 0 1-.904.525C13.022 7.711 11.573 8 10 8s-3.022-.289-4.096-.777A4.92 4.92 0 0 1 5 6.698V15c0 .374.356.875 1.318 1.313c.916.416 2.218.687 3.682.687s2.766-.27 3.682-.687C14.644 15.875 15 15.373 15 15V6.698z" fill="currentColor">
</path>
</g>
</svg>
""")
# Fluent Table20Regular
icon_table = NotStr("""
<svg name="table" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M17 5.5A2.5 2.5 0 0 0 14.5 3h-9A2.5 2.5 0 0 0 3 5.5v9A2.5 2.5 0 0 0 5.5 17h9a2.5 2.5 0 0 0 2.5-2.5v-9zm-13 9V13h3v3H5.5l-.144-.007A1.5 1.5 0 0 1 4 14.5zm8-1.5v3H8v-3h4zm2.5 3H13v-3h3v1.5l-.007.145A1.5 1.5 0 0 1 14.5 16zM12 8v4H8V8h4zm1 0h3v4h-3V8zm-1-4v3H8V4h4zm1 0h1.5l.145.007A1.5 1.5 0 0 1 16 5.5V7h-3V4zM7 4v3H4V5.5l.007-.144A1.5 1.5 0 0 1 5.5 4H7zm0 4v4H4V8h3z" fill="currentColor">
</path>
</g>
</svg>
""")

View File

@@ -0,0 +1,34 @@
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 AddStuffSettingsManager
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 = AddStuffSettingsManager(session, settings_manager)
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 Application")),
tabindex="0",
cls="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm"
),
cls="dropdown dropdown-hover"
)
@staticmethod
def create_component_id(session):
return f"{ADD_STUFF_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1,156 @@
import logging
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.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
from components.addstuff.settings import AddStuffSettingsManager, MyTable, 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 core.instance_manager import InstanceManager
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.repo_settings_manager = AddStuffSettingsManager(session, settings_manager)
self.tabs_manager = tabs_manager
self._contents = {} # ket tracks of already displayed contents
def request_new_repository(self):
# request for a new tab_id
new_tab_id = self.tabs_manager.request_new_tab_id()
# create a new form to ask for the details of the new database
add_repository_form = self._mk_add_repository_form(new_tab_id)
# create and display the form in a new tab
self.tabs_manager.add_tab("Add Database", add_repository_form, tab_id=new_tab_id)
return self.tabs_manager
def add_new_repository(self, tab_id: str, form_id: str, repository_name: str, table_name: str):
"""
: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
:return:
"""
try:
# Add the new repository and its default table to the list of repositories
tables = [MyTable(table_name, {})] if table_name else []
repository = self.repo_settings_manager.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),
title=table_name,
key=key,
active=True)
return self._mk_repository(repository, True), self.tabs_manager.refresh()
except ValueError as ex:
logger.debug(f" Repository '{repository_name}' already exists.")
add_repository_form = InstanceManager.get(self._session, form_id)
add_repository_form.set_error(ex)
return self.tabs_manager.refresh()
def select_repository(self, repository_name: str):
self.repo_settings_manager.select_repository(repository_name)
def show_table(self, repository_name: str, table_name: str):
key = (repository_name, table_name)
self.tabs_manager.add_tab(table_name, self._get_table_content(key), key)
return self.tabs_manager
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.repo_settings_manager.get_settings()
return Div(
*[self._mk_repository(repo, repo.name == settings.selected_repository_name)
for repo in settings.repositories],
id=self._id,
hx_swap_oob="true" if oob else None,
)
def _mk_repository(self, repo: Repository, selected):
return Div(
Input(type="radio",
name=f"repo-accordion-{self._id}",
checked="checked" if selected else None,
cls="p-0! min-h-0!",
hx_put=f"{ROUTE_ROOT}{Routes.SelectRepository}",
hx_vals=f'{{"_id": "{self._id}", "repository": "{repo.name}"}}',
# hx_trigger="changed delay:500ms",
),
Div(
mk_icon(icon_database, can_select=False), mk_ellipsis(repo.name),
cls="collapse-title p-0 min-h-0 flex truncate",
),
Div(
*[
Div(mk_icon(icon_table, can_select=False), mk_ellipsis(table.name),
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="collapse-content pr-0! truncate",
),
tabindex="0", cls="collapse mb-2")
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})
def _get_table_content(self, key):
if key in self._contents:
return self._contents[key]
dg = InstanceManager.get(self._session,
DataGrid.create_component_id(self._session),
DataGrid,
settings_manager=self._settings_manager,
key=key)
self._contents[key] = dg
return dg
@staticmethod
def create_component_id(session):
return f"{REPOSITORIES_INSTANCE_ID}{session['user_id']}"

View File

@@ -0,0 +1,9 @@
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"
ShowTable = "/show-table"

View File

@@ -0,0 +1,99 @@
import dataclasses
import logging
from core.settings_management import SettingsManager
from core.settings_objects import BaseSettingObj
ADD_STUFF_SETTINGS_ENTRY = "AddStuffSettings"
logger = logging.getLogger("AddStuffSettings")
@dataclasses.dataclass
class MyTable:
name: str
settings: dict | None = None
@dataclasses.dataclass
class Repository:
name: str
tables: list[MyTable]
@dataclasses.dataclass
class AddStuffSettings:
repositories: list[Repository] = dataclasses.field(default_factory=list)
selected_repository_name: str = None
class AddStuffSettingsManager(BaseSettingObj):
__ENTRY_NAME__ = ADD_STUFF_SETTINGS_ENTRY
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, ADD_STUFF_SETTINGS_ENTRY, default=AddStuffSettings())
def add_repository(self, repository_name: str, tables: list[MyTable] = 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, ADD_STUFF_SETTINGS_ENTRY, settings)
return repository
def add_table(self, repository_name: str, table_name: str, table_settings: dict):
"""
Adds a table to the specified repository
:param repository_name: The name of the target repository.
:param table_name: The name of the table to add.
:param table_settings: A dictionary containing the settings or configuration details
of the table.
:return: None
"""
settings = self.get_settings()
repository = next(filter(lambda r: r.name == repository_name, settings.repositories), None)
if repository is None:
raise ValueError(f"Repository '{repository_name}' does not exists.")
if table_name in (t.name for t in repository.tables):
raise ValueError(f"Table '{table_name}' already exists.")
repository.tables.append(MyTable(table_name, table_settings))
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)
def select_repository(self, repository_name: str):
"""
Select and save the specified repository name in the current session's settings.
:param repository_name: The name of the repository to be selected and stored.
:type repository_name: str
:return: None
"""
settings = self.get_settings()
settings.selected_repository_name = repository_name
self.settings_manager.put(self.session, ADD_STUFF_SETTINGS_ENTRY, settings)

View File

@@ -0,0 +1,308 @@
input:focus {
outline:none;
}
.dt-drag-drop {
display: none;
position: absolute;
top: 100%;
z-index: 5;
width: 100px;
border: 1px solid oklch(var(--b3));
border-radius: 10px;
padding: 10px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: oklch(var(--b1));
box-sizing: border-box;
overflow-x: auto;
pointer-events: none; /* Prevent interfering with mouse events */
}
.moving {
z-index: 2; /* Stay on top during animation */
transition: transform 0.3s ease; /* Smooth animation of column movement */
}
.dt-table {
display: flex;
flex-direction: column;
width: 100%;
border: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
border-radius: 10px;
}
.dt-table:focus {
outline: none;
}
.dt-header {
background-color: oklch(var(--b2));
border-radius: 10px 10px 0 0;
}
.dt-footer {
background-color: oklch(var(--b2));
border-radius: 0 0 10px 10px;
}
.dt-body {
max-height: 500px;
overflow-y: auto; /* to display the scrollbar */
font-size: 14px;
}
.dt-row {
display: flex;
width: 100%;
height: 22px;
}
.dt-cell {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 2px 8px;
position: relative;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 50px;
flex-grow: 0;
flex-shrink: 1;
box-sizing: border-box; /* to include the borders in the computations */
border-bottom: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
user-select: none;
}
.dt-cell:hover {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-choice-header-block {
position: relative; /* Ensure each column cell defines its positioning context */
justify-content: space-between; /* Space between content and resize handle */
user-select: none;
overflow: hidden;
min-width: 30px;
width: 50px;
}
.dt-choice-header-cell {
}
.dt-choice-resize-handle {
position: absolute;
right: 0;
top: 0;
width: 2px;
height: 100%;
cursor: col-resize;
}
.dt-choice-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
display: block; /* Makes it a block element */
width: 2px;
height: 60%;
top: calc(50% - 60%*0.5);
background-color: oklch(var(--n));
}
.dt-row-index-cell {
box-sizing: border-box;
border-right: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
border-bottom: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
width: 40px;
text-align: right;
user-select: none;
}
.dt-row-index-cell:hover {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-draggable.dragging {
cursor: grabbing;
}
.dt-cell-content {
text-align: inherit;
width:100%;
padding-right: 10px;
}
.dt-cell-is-editing {
width: 100%;
}
.dt-cell-input-list {
position: absolute;
top: 100%;
z-index: 1;
width: 200px;
border: 1px solid oklch(var(--b3));
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: oklch(var(--b1));
box-sizing: border-box;
}
.dt-dropdown-item {
display: block;
border-radius: 20px;
margin: 4px 5px;
}
.dt-dropdown-item.active {
background-color: #cce7ff; /* Highlight background */
color: #004085; /* Text color for active item */
}
.dt-dropdown-item:hover {
background-color: #cce7ff; /* Highlight background */
color: #004085; /* Text color for active item */
}
.dt-header-hidden {
width: 5px;
background: oklch(var(--n));
border-bottom: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
cursor: pointer;
}
.dt-col-hidden {
width: 5px;
border-bottom: 1px solid var(--fallback-bc, oklch(var(--bc) / .2));
}
.dt-tooltip-container {
background: oklch(var(--b3));
padding: 5px 10px;
border-radius: 4px;
pointer-events: none; /* Prevent interfering with mouse events */
font-size: 12px;
white-space: nowrap;
opacity: 0; /* Default to invisible */
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 */
}
.dt-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
}
.dt-selected-cell {
outline: 2px solid oklch(var(--p));
outline-offset: -3px; /* Ensure the outline is snug to the cell */
}
.dt-selected-cellx {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-selected-row {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-selected-column {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-hover-row {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-hover-column {
background-color: var(--fallback-bc, oklch(var(--p) / .2))
}
.dt-resizable {
position: relative;
}
.dt-resize-handle {
position: absolute;
right: 0;
top: 0;
width: 8px;
height: 100%;
cursor: col-resize;
}
.dt-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
display: block; /* Makes it a block element */
width: 3px;
height: 60%;
top: calc(50% - 60%*0.5);
background-color: oklch(var(--n));
}
.sort-icon {
margin-left: 2px;
}
.dt-highlight-1 {
color: oklch(var(--a));
}
.dt-filter-popup {
display: none;
position: absolute;
top: 100%;
right: 10;
z-index: 1;
width: 200px;
border: 1px solid oklch(var(--b3));
padding: 10px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: oklch(var(--b1));
box-sizing: border-box;
overflow-x: auto;
}
.dt-filter-popup-content {
max-height: 160px;
overflow-y: auto;
}
.dt-filter-popup.show {
display: block;
}
.dt-filter-popup input,
.dt-filter-popup label,
.dt-filter-popup button {
font-size: 0.75rem; /* Same as text-xs */
line-height: 1rem;
}
.dt-filter-popup label {
display: flex;
align-items: center;
gap: 5px;
}
.dt-filter-popup div {
margin-bottom: 5px;
}
.dt-filter-icon {
position: absolute;
right: 20px;
}
.dt-flex-grow {
flex-grow: 1;
height: 24px;
}

View File

@@ -0,0 +1,983 @@
const allowedDuringEdition = [
"Escape",
"Tab",
"Enter"
]
const emptyImage = new Image()
const dragDropMoveIcon = `<svg name="Move" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M7.146 4.354a.5.5 0 0 0 .708 0L9.5 2.707V6.5a.5.5 0 0 0 1 0V2.707l1.646 1.647a.5.5 0 0 0 .708-.708l-2.5-2.5a.5.5 0 0 0-.708 0l-2.5 2.5a.5.5 0 0 0 0 .708zm-2.792 3.5a.5.5 0 1 0-.708-.708l-2.5 2.5a.5.5 0 0 0 0 .708l2.5 2.5a.5.5 0 0 0 .708-.708L2.707 10.5H6.5a.5.5 0 0 0 0-1H2.707l1.647-1.646zm11.292 0a.5.5 0 0 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708l1.647-1.646H13.5a.5.5 0 0 1 0-1h3.793l-1.647-1.646zm-7.792 7.792a.5.5 0 0 0-.708.708l2.5 2.5a.5.5 0 0 0 .708 0l2.5-2.5a.5.5 0 0 0-.708-.708L10.5 17.293V13.5a.5.5 0 0 0-1 0v3.793l-1.646-1.647z" fill="currentColor">
</path>
</g>
</svg>`
const dragDropHideIcon = `<svg name="Hide" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M2.854 2.146a.5.5 0 1 0-.708.708l3.5 3.498a8.097 8.097 0 0 0-3.366 5.046a.5.5 0 1 0 .979.204a7.09 7.09 0 0 1 3.108-4.528L7.95 8.656a3.5 3.5 0 1 0 4.884 4.884l4.313 4.314a.5.5 0 0 0 .708-.708l-15-15zm7.27 5.857l3.363 3.363a3.5 3.5 0 0 0-3.363-3.363zM7.53 5.41l.803.803A6.632 6.632 0 0 1 10 6c3.206 0 6.057 2.327 6.74 5.602a.5.5 0 1 0 .98-.204C16.943 7.673 13.693 5 10 5c-.855 0-1.687.143-2.469.41z" fill="currentColor">
</path>
</g>
</svg>`
function bindTable(gridId, allowColumnReordering) {
makeResizable(gridId);
if (allowColumnReordering) {makeColumnsSortable(gridId);}
bindFilterIcons(gridId);
makeEditable(gridId);
bindTooltipsWithDelegation(gridId);
bindSelection(gridId);
}
function rebindDropDown(gridId, cellId) {
const dropdown = document.getElementById(`tcdd_${gridId}`);
const cell = document.getElementById(cellId);
if (!dropdown || !cell) {
console.warn("Dropdown or cell not found. Cannot bind dropdown.");
return;
}
// Position the dropdown
const rect = cell.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom}px`;
width = rect.width
if (width < 80) {width = 80}
dropdown.style.width = `${width}px`;
// Locate the input within the cell
const input = cell.querySelector("input");
if (input) {
// Remove existing event listeners to avoid duplication
input.removeEventListener("input", filterDropdown);
input.removeEventListener("keydown", handleKeyNavigation);
dropdown.querySelectorAll(".dt-dropdown-item").forEach(item => {
item.removeEventListener("click", selectItem);
});
// Filtering logic
function filterDropdown() {
const filter = input.value.toLowerCase();
dropdown.querySelectorAll(".dt-dropdown-item").forEach(item => {
item.style.display = item.textContent.toLowerCase().includes(filter)
? "block"
: "none";
});
}
// Event listener for filtering
input.addEventListener("input", filterDropdown);
// Event listener for selecting an item
function selectItem(e) {
const items = Array.from(dropdown.querySelectorAll(".dt-dropdown-item"))
.filter(item => item.style.display !== "none")
.forEach(item => item.classList.remove("active"));
input.value = e.target.textContent.trim(); // Update input with item value
e.target.classList.add("active");
}
dropdown.querySelectorAll(".dt-dropdown-item").forEach(item => {
item.addEventListener("click", selectItem);
});
// Keyboard navigation
let currentIndex = -1; // Track the current highlighted item
function handleKeyNavigation(e) {
const items = Array.from(dropdown.querySelectorAll(".dt-dropdown-item"))
.filter(item => item.style.display !== "none"); // Only visible items
if (items.length === 0) return; // No items to navigate
if (e.key === "ArrowDown") {
// Move focus down
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length; // Wrap around
updateActiveItem(items);
items[currentIndex].click()
} else if (e.key === "ArrowUp") {
// Move focus up
e.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length; // Wrap around
updateActiveItem(items);
items[currentIndex].click()
}
// else if (e.key === "Enter") {
// // Select the active item
// //e.preventDefault();
// if (currentIndex >= 0) {
// items[currentIndex].click(); // Trigger click event
// }
// }
}
function updateActiveItem(items) {
// Remove `active` class from all items
items.forEach(item => item.classList.remove("active"));
// Add `active` class to the current item
if (currentIndex >= 0) {
items[currentIndex].classList.add("active");
items[currentIndex].scrollIntoView({ block: "nearest" }); // Ensure it's visible
}
}
// Add keydown listener for navigation
input.addEventListener("keydown", handleKeyNavigation);
} else {
dropdown.style.display = "none"; // Optionally hide the dropdown if no input exists
}
}
/**
* Makes the table editable by binding click and custom htmx events.
*
* @param {string} gridId - The ID of the grid/table to make editable.
*
* @description
* - This function sets up event listeners for enabling cell editing within the table.
* - Ensures only one cell is edited at a time and prevents unwanted POST requests.
* - Binds `click` and `htmx:beforeRequest` event listeners to enable/disable editing and
* prevent interactions when editing a cell.
*
* Behavior:
* - Listens for user clicks to detect the targeted cell.
* - Stops `htmx:beforeRequest` if a cell is in an editing state to prevent redundant updates.
*
* Example:
* ```javascript
* makeEditable('exampleGridId');
* ```
*
* Error Handling:
* - Logs an error to the console if the table body for the provided grid ID is not found.
*/
function makeEditable(gridId) {
tableBodyId = 'tb_' + gridId
const tableBody = document.getElementById(tableBodyId);
if (!tableBody) {
console.error(`Table body with ID "${tableBody}" not found.`);
return;
}
let clickedCell = null;
// Capture the clicked cell
tableBody.addEventListener('click', (event) => {
clickedCell = event.srcElement;
});
tableBody.addEventListener('htmx:beforeRequest', (event) => {
if (!clickedCell || !event.target.classList.contains('dt-body')) {
return;
}
const cell = clickedCell.parentElement;
if (cell && cell.classList.contains('dt-cell-is-editing')) {
// Prevent the POST if the cell is already being edited
event.preventDefault();
}
});
}
function swapColumns(grid, draggedColumn, targetColumn) {
// Locate the dragged and target header cells using 'data-col'
const draggedHeader = grid.querySelector(`.dt-draggable[data-col="${draggedColumn}"]`);
const targetHeader = grid.querySelector(`.dt-draggable[data-col="${targetColumn}"]`);
// stop if already moving
if (draggedHeader.classList.contains('moving')) {
return
}
// Add a visual transition
const draggedRect = draggedHeader.getBoundingClientRect();
const targetRect = targetHeader.getBoundingClientRect();
const deltaX = targetRect.left - draggedRect.left;
const draggedCells = grid.querySelectorAll(`.dt-cell[data-col="${draggedColumn}"]`);
draggedCells.forEach(cell => {
cell.classList.add('moving');
cell.style.transform = `translateX(${deltaX}px)`;
});
const targetCells = grid.querySelectorAll(`.dt-cell[data-col="${targetColumn}"]`);
targetCells.forEach(cell => {
cell.classList.add('moving');
cell.style.transform = `translateX(${-deltaX}px)`;
});
// Wait for the animation to finish, then swap the elements and clean up
setTimeout(() => {
// Reset the transform to prevent "jumping"
draggedCells.forEach(cell => {
cell.classList.remove('moving');
cell.style.transform = "";
});
targetCells.forEach(cell => {
cell.classList.remove('moving');
cell.style.transform = "";
});
// Perform the actual DOM update
const draggedIsBefore = draggedHeader.nextElementSibling === targetHeader
for (let i = 0; i < draggedCells.length; i++) {
// Swap header cells in the DOM
const draggedCell = draggedCells[i];
const targetCell = targetCells[i];
if (draggedIsBefore) {
targetCell.parentNode.insertBefore(targetCell, draggedCell);
} else {
targetCell.parentNode.insertBefore(draggedCell, targetCell);
}
}
}, 300); // Matches the duration of the CSS transition
}
function makeColumnsSortable(gridId) {
const grid = document.getElementById(gridId);
const headerRow = document.getElementById(`th_${gridId}`)
const dragElement = document.getElementById(`tdd_${gridId}`);
const dragElementLabel = dragElement.querySelector('label');
const dragElementIcon = dragElementLabel.querySelector(".icon")
const headerCells = grid.querySelectorAll('.dt-draggable');
let draggedColumn = null;
let draggedColumnIndex = null
headerCells.forEach(cell => {
// Prevent dragging on resize handle
resizableClasses = [".dt-resize-handle", ".dt-choice-resize-handle"]
resizableClasses.forEach(resizableClassName => {
const resizeHandles = cell.querySelectorAll(resizableClassName);
resizeHandles.forEach( resizeHandle => {
resizeHandle.addEventListener('mousedown', (event) => {
event.stopPropagation(); // Prevent triggering dragstart
event.preventDefault();
});
})
})
// const resizeHandle = cell.querySelector('.dt-resize-handle');
// if (resizeHandle) {
// resizeHandle.addEventListener('mousedown', (event) => {
// event.stopPropagation(); // Prevent triggering dragstart
// event.preventDefault();
// });
// }
cell.addEventListener('dragstart', (event) => {
draggedColumn = cell.getAttribute('data-col'); // Store the dragged column ID
draggedColumnIndex = _getColumnPos(headerRow, draggedColumn)
event.dataTransfer.setData('text/plain', draggedColumn);
dragElementLabel.lastChild.textContent = cell.getAttribute('data-tooltip')
dragElementIcon.innerHTML = dragDropMoveIcon
dragElement.style.display = "block"
dragElement.style.left = `${event.pageX - (dragElement.offsetWidth / 2)}px`;
dragElement.style.top = `${event.pageY - 40}px`;
event.dataTransfer.setDragImage(emptyImage, 0, 0);
cell.classList.add('dragging');
});
cell.addEventListener('dragenter', (event) => {
event.preventDefault(); // Allow drop
const targetColumn = cell.getAttribute('data-col');
if (draggedColumn && draggedColumn !== targetColumn) {
swapColumns(grid, draggedColumn, targetColumn);
}
});
cell.addEventListener('dragover', (event) => {
event.preventDefault(); // Allow drop
});
cell.addEventListener('drop', (event) => {
event.preventDefault();
const targetColumn = cell.getAttribute('data-col');
const targetColumnIndex = _getColumnPos(headerRow, targetColumn)
if (draggedColumnIndex !== targetColumnIndex) {
// Perform the drop action via HTMX
htmx.ajax('POST', '/datagrid/on_state_changed', {
target : "#t_" + gridId,
headers: { "Content-Type": "application/x-www-form-urlencoded"},
values: { g_id: gridId, attribute: "move", col_ids: draggedColumn, old_value: draggedColumnIndex, new_value: targetColumnIndex }
});
}
});
cell.addEventListener('dragend', () => {
headerCells.forEach(cell => cell.classList.remove('dragging'));
draggedColumn = null; // Reset dragged column
dragElement.style.display = "none"
});
});
// // Listen for drag events on the entire document to show "remove" feedback
// document.addEventListener('dragenter', (event) => {
//
// table = event.target.closest(`#t_${gridId}`)
// if (!table) {
// // Dragging outside the grid
// event.preventDefault(); // Necessary to allow dropping
// event.dataTransfer.effectAllowed = "copyMove";
// document.body.classList.add('drag-remove'); // Apply a custom cursor via CSS
// dragElementIcon.innerHTML = dragDropHideIcon
// } else {
// // Dragging back inside the grid
// document.body.classList.remove('drag-remove');
// dragElementIcon.innerHTML = dragDropMoveIcon
// }
// });
//
document.addEventListener('dragover', (event) => {
dragElement.style.left = `${event.pageX - (dragElement.offsetWidth / 2)}px`;
dragElement.style.top = `${event.pageY - 40}px`;
});
//
// document.addEventListener('drop', (event) => {
//
// if (document.body.classList.contains('drag-remove')) {
// event.preventDefault();
// const droppedColumn = event.dataTransfer.getData('text/plain');
//
// // Use HTMX or another mechanism to handle the column removal
// htmx.ajax('POST', '/datagrid/on_column_changed', {
// target: "#t_" + gridId,
// headers: { "Content-Type": "application/x-www-form-urlencoded" },
// values: { g_id: gridId, attribute: "remove", col_ids: droppedColumn }
// });
//
// // Reset cursor style
// document.body.classList.remove('drag-remove');
// }
// });
//
// // Reset the custom cursor if the drag operation is canceled or ends without dropping
// document.addEventListener('dragend', () => {
// document.body.classList.remove('drag-remove');
// });
}
/**
* Binds hover and selection interactions to a data grid's rows and cells.
*
* @param {string} gridId - The ID of the grid element to bind interactions to.
*
* @description
* The `bindSelection` function applies hover effects to rows and columns
* within a data grid based on the current selection mode. It dynamically highlights
* entire rows or columns when the user hovers over a specific cell, providing visual
* feedback for improved user experience. The selection mode (`row` or `column`)
* is determined dynamically via an element linked to the grid.
*
* Functionality:
* - Adds hover listeners to cells.
* - Highlights the row or column on hover, depending on the active selection mode.
* - Removes highlighting effects when the hover ends.
*
* Error Handling:
* Logs an error to the console if the grid element with the provided ID is not found.
*
* CSS Classes Used:
* - `dt-hover-row`: Highlight rows during hover.
* - `dt-hover-column`: Highlight columns during hover.
*
* Dependencies:
* Requires specific HTML structure:
* - Cells should have a `data-col` attribute for identifying columns.
* - Grid should contain an element with an ID of `tsm_<gridId>` that provides
* the current selection mode (`row` or `column`).
*/
function bindSelection(gridId) {
const grid = document.getElementById(gridId);
if (!grid) {
console.error(`Grid with id "${gridId}" not found.`);
return;
}
const rows = grid.querySelectorAll('.dt-row');
const cells = grid.querySelectorAll('.dt-cell');
function toggleHighlight(elements, className, add = true) {
elements.forEach(element => {
if (add) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
});
}
// Add hover listeners for each cell
cells.forEach(cell => {
cell.addEventListener('mouseenter', () => {
const selectionModeDiv = document.getElementById(`tsm_${gridId}`);
const selectionMode = selectionModeDiv.getAttribute('selection-mode'); // Get the latest selection mode
const rowElement = cell.parentElement;
const colIndex = cell.dataset.col;
if (selectionMode === 'row') {
const rowElement = cell.parentElement;
toggleHighlight([rowElement], 'dt-hover-row', true);
} else if (selectionMode === 'column') {
const columnCells = Array.from(cells).filter(c => c.dataset.col === colIndex);
toggleHighlight(columnCells, 'dt-hover-column', true);
}
});
cell.addEventListener('mouseleave', () => {
const rowElement = cell.parentElement;
const colIndex = cell.dataset.col;
toggleHighlight([rowElement], 'dt-hover-row', false);
const columnCells = Array.from(cells).filter(c => c.dataset.col === colIndex);
toggleHighlight(columnCells, 'dt-hover-column', false);
});
});
}
function scrollToSelected(gridId) {
tableBodyId = 'tb_' + gridId
const tableBody = document.getElementById(tableBodyId);
const selectedCell = tableBody.querySelector('.dt-selected-cell');
if (selectedCell) {
// Scroll the table to make the cell visible
selectedCell.scrollIntoView({
behavior: 'smooth', // Smooth scrolling for better UX
block: 'nearest', // Scroll as little as possible vertically
inline: 'nearest', // Scroll as little as possible horizontally
});
}
}
/**
Rebind all the tooltips that may have changed
*/
function rebindCellsTooltips(gridId, cellHints) {
cellHints.forEach((cellHint) => {
if (cellHint.endsWith('*')) {
// Wildcard case: Find all cells that start with the hint (after removing the '*')
const prefix = cellHint.slice(0, -1);
const matchingCells = Array.from(
document.querySelectorAll(`[id^="${prefix}"]`)
);
matchingCells.forEach((cell) => {
bindCellTooltip(gridId, cell);
});
} else {
// Exact case: Find the cell by exact ID
const cell = document.getElementById(cellHint);
if (cell) {
bindCellTooltip(gridId, cell);
} else {
console.warn(`Cell with ID "${cellHint}" not found.`);
}
}
});
}
/**
Make sure that the toolip correct and activate on a specific cell
*/
function bindCellTooltip(gridId, cell) {
const tooltip_id = 'tt_' + gridId;
const tooltipContainer = document.getElementById(tooltip_id);
cell.addEventListener("mouseenter", (event) => {
const content = cell.querySelector('[name="dt-cell-content"]') || cell.querySelector('[name="dt-header-title"]') || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains('dt-header-hidden') || cell.classList.contains('dt-tooltip')
if (isOverflowing || forceShow) {
const tooltipText = cell.getAttribute("data-tooltip");
if (tooltipText) {
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
const rect = cell.getBoundingClientRect();
const tooltipRect = tooltipContainer.getBoundingClientRect();
let top = rect.top - 30; // Above the cell
let left = rect.left;
// Adjust if tooltip goes off-screen
if (top < 0) top = rect.bottom + 5; // Move below the cell
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 5; // Adjust to fit
}
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
}
}
});
cell.addEventListener("mouseleave", () => {
tooltipContainer.setAttribute("data-visible", "false");
});
}
/***
Bind all the tooltips of the grid
***/
function bindTooltips(gridId) {
const datagrid = document.getElementById(gridId);
datagrid.querySelectorAll("div[data-tooltip]").forEach((cell) => {
bindCellTooltip(gridId, cell)
});
}
function generateKeyEventPayload(gridId, event) {
const key = event.key;
const underEdition = document.querySelector('.dt-cell-is-editing'); // Adjust selector to match your cell structure
if (!underEdition) {
return { g_id: gridId, key }; // Default payload if no cell is focused
}
// Extract additional context about the current cell
const innerInputElt = underEdition.querySelector("input"); // Get current cell value
const cellValue = innerInputElt.value
// Build and return the payload
return {
g_id: gridId,
key,
arg: cellValue,
};
}
function onCellEdition(event) {
const table = event.target.closest(".dt-table")
const triggerEvent = event.detail.requestConfig.triggeringEvent
if (triggerEvent instanceof KeyboardEvent) {
const underEdition = table.querySelector('div.dt-cell-is-editing');
if (underEdition && !allowedDuringEdition.includes(triggerEvent.code)) {
event.preventDefault();
}
}
else if (triggerEvent instanceof PointerEvent) {
const underEdition = triggerEvent.target.closest('.dt-cell-is-editing')
if (underEdition) {
event.preventDefault();
}
}
}
function setFocus(gridId, event) {
const isEditingElt = document.querySelector('div.dt-cell-is-editing')
if (isEditingElt) {
const input = isEditingElt.querySelector('input');
if (input) { input.focus();}
} else {
const tableElt = document.getElementById('t_' + gridId);
tableElt.focus();
}
}
function setSelected(gridId) {
const selectionManager = document.getElementById(`tsm_${gridId}`);
if (!selectionManager) return;
// Clear previous selections
document.querySelectorAll('.dt-selected-cell, .dt-selected-cellx, .dt-selected-row, .dt-selected-column').forEach((element) => {
element.classList.remove('dt-selected-cell', 'dt-selected-cellx', 'dt-selected-row', 'dt-selected-column');
element.style.userSelect = 'none';
});
// Loop through the children of the selection manager
Array.from(selectionManager.children).forEach((selection) => {
const selectionType = selection.getAttribute('selection-type');
const elementId = selection.getAttribute('element-id');
if (selectionType === 'cell') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt-selected-cell');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'cellx') {
const cellElement = document.getElementById(`${elementId}`);
if (cellElement) {
cellElement.classList.add('dt-selected-cellx');
cellElement.style.userSelect = 'text';
}
} else if (selectionType === 'row') {
const rowElement = document.getElementById(`${elementId}`);
if (rowElement) {
rowElement.classList.add('dt-selected-row');
}
} else if (selectionType === 'column') {
// Select all elements in the specified column
document.querySelectorAll(`[data-col="${elementId}"]`).forEach((columnElement) => {
columnElement.classList.add('dt-selected-column');
});
}
});
}
function getClickModifier(event) {
if (event instanceof PointerEvent) {
res = ""
if (event.altKey) {res += "alt-"}
if (event.ctrlKey) {res += "ctrl-"}
if (event.metaKey) {res += "meta-"}
if (event.shiftKey) {res += "shift-"}
return res
}
return null
}
/********************************
* R E S I Z E R
********************************/
function setColumnsWidths(gridId, autoSize) {
const tableId = 't_' + gridId;
const headerId = 'th_' + gridId;
const sidebar = document.querySelector('.sidebar');
const main = document.querySelector('.main');
const table = document.getElementById(tableId);
const header = document.getElementById(headerId); // Get the header by its ID
const headerCells = header.querySelectorAll('.dt-cell');
const resizerHandles = table.querySelectorAll('.dt-resize-handle');
const resizerTotalWidth = resizerHandles.length * 8;
const sidebarWidth = sidebar.getBoundingClientRect().width;
const mainStyle = getComputedStyle(main);
const paddingLeft = parseFloat(mainStyle.paddingLeft);
const paddingRight = parseFloat(mainStyle.paddingRight);
const totalWidth = window.innerWidth - sidebarWidth - paddingLeft - paddingRight; // Total table width
let fixedWidthTotal = 0; // Total width of columns that already have a defined width
let remainingColumns = []; // Columns that do not have a defined width
// Calculate fixed widths and identify remaining columns based on dt-fixed-col class
headerCells.forEach(headerCell => {
if (headerCell.classList.contains('dt-fixed-col')) {
// If the header cell has a fixed width, accumulate it
const fixedWidth = parseFloat(headerCell.style.width);
fixedWidthTotal += fixedWidth;
} else {
// If no fixed width is defined, add the column to the remaining columns list
remainingColumns.push(headerCell);
}
});
// Calculate the remaining width for columns that do not have a width defined
const remainingWidth = totalWidth - fixedWidthTotal - resizerTotalWidth;
const columnWidth = remainingWidth / remainingColumns.length;
if (autoSize) {
alignCellsWidths(gridId)
headerCells.forEach(headerCell => {
const colIndex = headerCell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt-cell[data-col="${colIndex}"]`);
if (headerCell.classList.contains('dt-fixed-col')) {
// Ensure all cells in this column have the same fixed width as the header
const fixedWidth = headerCell.style.width;
cells.forEach(cell => {
cell.style.width = fixedWidth;
});
} else {
// For columns without a predefined width, apply the calculated width
cells.forEach(cell => {
cell.style.width = `${columnWidth}px`;
});
}
});
}
else {
headerCells.forEach(headerCell => {
const colIndex = headerCell.getAttribute('data-col');
const headerWidth = headerCell.offsetWidth;
const cells = table.querySelectorAll(`.dt-cell[data-col="${colIndex}"]`);
cells.forEach(cell => {
cell.style.width = `${headerWidth}px`; // Adjust the body and footer widths
});
});
}
}
function alignCellsWidths(gridId) {
const tableElement = document.getElementById('t_' + gridId);
const headerElement = document.getElementById('th_' + gridId);
const allColumns = headerElement.querySelectorAll('[data-col]');
console.log(allColumns)
allColumns.forEach(column => {
const colId = column.getAttribute('data-col');
const columnWidth = column.offsetWidth;
const cells = tableElement.querySelectorAll(`[data-col="${colId}"]`);
cells.forEach(cell => {
cell.style.width = `${columnWidth}px`; // Adjust the body and footer widths
});
})
}
function makeResizable(gridId) {
tableId = 't_' + gridId
const table = document.getElementById(tableId);
const resizers = table.querySelectorAll('.dt-resize-handle');
resizers.forEach(resizer => {
resizer.addEventListener('mousedown', onMouseDown);
resizer.addEventListener('dblclick', onDoubleClick); // Handle double-clicks
});
function onMouseDown(e) {
const cell = e.target.parentElement;
const startX = e.pageX;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt-cell[data-col="${colIndex}"]`);
const startWidth = cell.offsetWidth + 8;
const oldValue = `${cell.offsetWidth}px`;
let newWidth = startWidth;
// Capture the initial offset
const offset = startX - (cell.getBoundingClientRect().right - window.scrollX);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
function onMouseMove(e) {
newWidth = startWidth + (e.pageX - startX) + offset; // Adjust based on the offset
cells.forEach(cell => {
cell.style.width = `${newWidth}px`; // Adjust width of the selected column
});
}
function onMouseUp() {
// First remove the handlers
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// mark this column as fixed
cell.classList.add('dt-fixed-col');
// tell the server that the columns must not be automatically re
// onColumnChanged(gridId, "width", colIndex, oldValue, `${newWidth}px`)
// .catch(error => console.error("Error:", error));
}
}
function onDoubleClick(e) {
const cell = e.target.parentElement;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt-cell[data-col="${colIndex}"]`);
cells.forEach(cell => {
cell.style.width = ''; // Reset to default size
});
}
}
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
}
const parentElement = findParentByName(event.target, 'dt-body-cell')
return parentElement ? parentElement.id : null;
}
function getCellValue(event) {
const underEdition = document.querySelector("div.dt-cell-is-editing");
if (underEdition) {
const inputElement = underEdition.querySelector("input");
if (inputElement) {
return inputElement.value;
}
}
return null;
}
/********************************
* F I L T E R P O P - U P : S H O W C O R R E C T P O P U P
********************************/
// Add click event to all filter icons
function bindFilterIcons(gridId) {
tableId = 't_' + gridId
const table = document.getElementById(tableId);
table.querySelectorAll('.dt-filter-icon').forEach(icon => {
icon.addEventListener('click', function (event) {
event.stopPropagation(); // Prevent the click from propagating to the document
const popup = this.nextElementSibling; // Get the associated popup
// If the popup is already visible, close it
if (popup.classList.contains('show')) {
popup.classList.remove('show');
} else {
// Close all other open popups
document.querySelectorAll('.dt-filter-popup').forEach(p => {
p.classList.remove('show');
});
// Show the popup for the clicked icon
popup.classList.add('show');
}
});
});
// Prevent closing the popup when clicking inside the popup
document.querySelectorAll('.dt-filter-popup').forEach(popup => {
popup.addEventListener('click', function(event) {
event.stopPropagation(); // Prevent clicks inside the popup from closing it
});
});
}
function bindPopup(popupId) {
const popup = document.getElementById(popupId);
const allCheckbox = popup.querySelector('input[value="__all__"]');
const otherCheckboxes = popup.querySelectorAll('input[type="checkbox"]:not([value="__all__"])');
const filterInput = popup.querySelector('input[type="text"]'); // Assuming this is the filter input
// Checkboxes change behavior
allCheckbox.addEventListener('change', function () {
otherCheckboxes.forEach(checkbox => {
if (checkbox.parentElement.offsetParent !== null) {
checkbox.checked = allCheckbox.checked;
}
});
});
// Filter checkboxes based on input
filterInput.addEventListener('input', function () {
const filterValue = filterInput.value.toLowerCase(); // Convert to lowercase for case insensitive matching
otherCheckboxes.forEach(checkbox => {
const label = checkbox.parentElement.textContent.toLowerCase(); // Get label text and convert to lowercase
checkbox.parentElement.style.display = label.includes(filterValue) ? '' : 'none'; // Show or hide checkbox based on match
});
});
}
function hidePopup(popupId) {
const popup = document.getElementById(popupId);
popup.classList.remove('show');
}
function getSelectedValues(popupId) {
const popup = document.getElementById(popupId);
const otherCheckboxes = popup.querySelectorAll('input[type="checkbox"]:not([value="__all__"])');
const checkedValues = Array.from(otherCheckboxes)
.filter(checkbox => checkbox.checked && checkbox.parentElement.offsetParent !== null) // Check if visible
.map(checkbox => checkbox.value); // Get only the values
return JSON.stringify(checkedValues); // Convert to JSON string
}
function _getColumnPos(headerRow, columnId) {
const validChildren = Array.from(headerRow.children).filter(child =>
child.getAttribute('data-col') !== "-1"
);
return draggedColumnIndex = validChildren.findIndex(child =>
child.getAttribute('data-col') === columnId
);
}
// Close the popup when clicking outside
document.addEventListener('click', function() {
document.querySelectorAll('.dt-filter-popup').forEach(popup => {
popup.classList.remove('show');
});
});
//htmx.logAll();
//<div id="objectDetail"
// hx-get="/initial-content/"
// hx-swap="innerHTML"
// hx-trigger="pageLoaded"
// placeholder="Loading..." />
//</div>
//
//
//<script>
// $(document).ready(function() {
// htmx.trigger("#objectDetail", "pageLoaded")
// });
//</script>script>
// Send htmx request
// htmx.ajax('POST', '/datagrid/on_state_changed', {
// target : "#t_" + gridId,
// headers: { "Content-Type": "application/x-www-form-urlencoded"},
// values: { g_id: gridId, attribute: "move", col_ids: draggedColumn, old_value: draggedColumnIndex, new_value: targetColumnIndex }
// });
// Method - htmx.ajax()
// Issues an htmx-style AJAX request. This method returns a Promise, so a callback can be executed after the content has been inserted into the DOM.
//
// Parameters
// verb - GET, POST, etc.
// path - the URL path to make the AJAX
// element - the element to target (defaults to the body)
// or
//
// verb - GET, POST, etc.
// path - the URL path to make the AJAX
// selector - a selector for the target
// or
//
// verb - GET, POST, etc.
// path - the URL path to make the AJAX
// context - a context object that contains any of the following
// source - the source element of the request, hx-* attrs which affect the request will be resolved against that element and its ancestors
// event - an event that “triggered” the request
// handler - a callback that will handle the response HTML
// target - the target to swap the response into
// swap - how the response will be swapped in relative to the target
// values - values to submit with the request
// headers - headers to submit with the request
// select - allows you to select the content you want swapped from a response
// Example
// // issue a GET to /example and put the response HTML into #myDiv
// htmx.ajax('GET', '/example', '#myDiv')
//
// // issue a GET to /example and replace #myDiv with the response
// htmx.ajax('GET', '/example', {target:'#myDiv', swap:'outerHTML'})
//
// // execute some code after the content has been inserted into the DOM
// htmx.ajax('GET', '/example', '#myDiv').then(() => {
// // this code will be executed after the 'htmx:afterOnLoad' event,
// // and before the 'htmx:xhr:loadend' event
// console.log('Content inserted successfully!');
// });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
from components.datagrid.constants import *
class DataGridCommandManager:
def __init__(self, datagrid):
self.datagrid = datagrid
self._id = self.datagrid.get_grid_id()
def hide_columns(self, col_ids, cls=""):
return self._get_hide_show_columns_attrs("Hide", col_ids, "false", cls=cls)
def show_columns(self, col_ids, cls=""):
return self._get_hide_show_columns_attrs("Show", col_ids, "true", cls=cls)
def hide_rows(self, row_indexes, cls=""):
return self._get_hide_show_rows_attrs("Hide", row_indexes, "false", cls=cls)
def show_rows(self, row_indexes, cls=""):
return self._get_hide_show_rows_attrs("Show", row_indexes, "true", cls=cls)
def reset_filters(self, cls=""):
return {"hx_post": f"{DATAGRID_PATH}/reset_filter",
"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 _get_hide_show_rows_attrs(self, mode, row_indexes, new_value, cls=""):
if not isinstance(row_indexes, (list, tuple)):
row_indexes = [row_indexes]
row_indexes = sorted(row_indexes)
str_rows_indexes = ", ".join(str(row_index) for row_index in row_indexes)
tooltip_msg = f"{mode} row{'s' if len(row_indexes) > 1 else ''} {str_rows_indexes}"
old_value = "true" if new_value == "false" else "false"
return {
"hx_post": f"/{DATAGRID_PATH}/on_state_changed",
"hx_vals": f'{{"g_id": "{self._id}", "attribute": "{DG_STATE_ROW_VISIBILITY}", "col_ids":"{str_rows_indexes}", "old_value": {old_value}, "new_value": {new_value} }}',
"hx_target": f"#t_{self._id}",
"hx_swap": "outerHTML",
"data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "dt-tooltip")
}
def _get_hide_show_columns_attrs(self, mode, col_ids, new_value, cls=""):
if not isinstance(col_ids, (list, tuple)):
col_ids = [col_ids]
str_col_names = ", ".join(f"'{self._grid_settings[DG_COLUMNS][col_id][TITLE_KEY]}'" for col_id in col_ids)
str_col_ids = ",".join(str(col_id) for col_id in col_ids)
tooltip_msg = f"{mode} column{'s' if len(col_ids) > 1 else ''} {str_col_names}"
old_value = "true" if new_value == "false" else "false"
return {
"hx_post": f"/{DATAGRID_PATH}/on_state_changed",
"hx_vals": f'{{"g_id": "{self._id}", "attribute": "{DG_STATE_COLUMN_VISIBILITY}", "col_ids":"{str_col_ids}", "old_value": {old_value}, "new_value": {new_value} }}',
"hx_target": f"#t_{self._id}",
"hx_swap": "outerHTML",
"data_tooltip": tooltip_msg,
"cls": self.merge_class(cls, "dt-tooltip")
}
def _get_col_id_from_col_index(self, col_index):
for column_id, conf in self._grid_settings[DG_COLUMNS].items():
if conf[INDEX_KEY] == col_index:
return column_id
raise KeyError(col_index)
@property
def _grid_settings(self):
return self.datagrid.get_grid_settings()
@staticmethod
def merge(*items):
"""
Merges multiple dictionaries into a single dictionary by combining their key-value pairs.
If a key exists in multiple dictionaries and its value is a string, the values are concatenated.
If the key's value is not a string, an error is raised.
:param items: dictionaries to be merged. If all items are None, None is returned.
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
"""
if all(item is None for item in items):
return None
res = {}
for item in [item for item in items if item is not None]:
for key, value in item.items():
if not key in res:
res[key] = value
else:
if isinstance(res[key], str):
res[key] += " " + value
else:
raise NotImplementedError("")
return res
@staticmethod
def merge_class(cls1, cls2):
return (cls1 + " " + cls2) if cls2 else cls1

View File

@@ -0,0 +1,144 @@
# Datagrid Module
## Overview
The Datagrid module provides a flexible and powerful data grid component. This module includes attributes, classes, and
methods for managing and displaying data grids effectively.
## Attributes
- `pd`
- `DATAGRID_PATH`
- `ID_PREFIX`
- `COLUMNS_KEY`
- `SORT_KEY`
- `FILTER_KEY`
- `FILTER_ALL_KEY`
- `datagrid_app`
- `rt`
- `_instances`
## Functions
### `reset_instances()`
Resets the instances of the datagrid component.
### `post()`
Multiple implementations of the `post` method are available, handling various aspects of data posting.
## Classes
### `SortDefinition`
Defines sorting attributes.
- `column_id`
- `direction`
### `FilterDefinition`
Defines filtering attributes.
- `column_id`
- `values`
### `DataGrid`
A comprehensive class managing datagrids.
- **Methods**:
- `sort()`
- `filter()`
- `reset_filter()`
- `import_file()`
- **Retrieve components**:
- `get_filter_input()`
- `get_reset_filter_button()`
- `get_import_input()`
- `get_table()`
-
- **Datagrid ids**:
using `Datagrid(id=my_id)`
| Name | value |
|-------------------------|----------------------------------------|
| datagrid object | `datagrid-my_id` |
| tooltip | `tt_datagrid-my_id` |
| context menu | `cm_datagrid-my_id` |
| cm - selection mode | `cmsm_datagrid-my_id` |
| cm - row management | `cmrm_datagrid-my_id` |
| cm - row mgt - hide | `cmcrh_datagrid-my_id` |
| cm - row mgt - show | `cmcrs_datagrid-my_id` |
| cm - columns management | `cmcm_datagrid-my_id` |
| cm - col mgt - hide | `cmcmh_datagrid-my_id` |
| cm - col mgt - show | `cmcms_datagrid-my_id` |
| table | `t_datagrid-my_id` |
| table header | `th_datagrid-my_id` |
| table body | `tb_datagrid-my_id` |
| table footer | `tf_datagrid-my_id` |
| table cell | `tc_datagrid-my_id-colindex-row-index` |
| table cell drop down | `tcdd_datagrid-my_id` |
| table row | `tr_datagrid-my_id-row-index` |
| table selection manager | `tsm_datagrid-my_id` |
| table sort icon | `tsi_datagrid-my_id` |
| filter all component | `fa_datagrid-my_id` |
| filter all input | `fi_datagrid-my_id` |
| filter popup | `fp_datagrid-my_id_column_id` |
| file name input | `fn_datagrid-my_id` |
| sheet name input | `sn_datagrid-my_id` |
- **Grid settings**:
| Name | Definition | Possible Values | Default Value |
|--------------------|--------------------------------------|-----------------|---------------|
| GS_FILTER_INPUT | display or hide the Filter Component | True, False | True |
| GS_TABLE_HEADER | Display or hide the header | True, False | True |
| GS_TABLE_FOOTER | Display or hide the footer | True, False | True |
| GS_TABLE_READ_ONLY | Whether or not the table is readonly | True, False | True |
| GS_COLUMNS | Columns Definitions | Dictionary | None |
- **Columns data types**:
| Name | Type |
|----------------------|--------------------|
| GS_DATATYPE_NUMBER | int or float |
| GS_DATATYPE_STRING | string or whatever |
| GS_DATATYPE_DATETIME | datetime |
| GS_DATATYPE_BOOL | boolean |
## Useful definitions
| Name | Definition |
|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| col_id | unique `id` of the column. It's a unique name. There is a one to one between column_id and colum_index |
| col_index | `index` of the column, to be used in the dataframe.<br/> At startup, its computed using the index taken from the grid_settings |
| row_index | `index` of the row, to be used in the dataframe |
| col_pos | `position` of the column. Index of the visible rows only.<br/> So the columns are filtered or reordered, col_pos and col_index are no longer the same |
| row_pos | `position` of the row. Index of the visible rows only.<br/> So the rows are filtered, row_index and row_pos are no longer the same |
## Usage Example
```python
# importing DataGrid
from components.Datagrid import DataGrid
# creating an instance of DataGrid
data_grid = DataGrid()
# importing a file
data_grid.import_file('data.csv')
# Applying filters and sorts as defined
data_grid.filter(column_id='age', values=[30, 40])
data_grid.sort(column_id='name', direction='asc')
# Retrieving state
state = data_grid.get_state()
# Getting table for display
table_html = data_grid.mk_table()
```

View File

View File

@@ -0,0 +1,66 @@
DATAGRID_PATH = "datagrid"
ID_PREFIX = "datagrid"
SORT_KEY = "sort"
FILTER_KEY = "filter"
WIDTH_KEY = "width"
VISIBLE_KEY = "visible"
INDEX_KEY = "index"
SAFE_INDEX_KEY = "safe_index"
TITLE_KEY = "title"
MOVE_KEY = "move"
ROW_INDEX_KEY = "-1"
IMPORT_FILE_INPUT_KEY = "import_file_input"
CELL_UNDER_EDITION_KEY = "cell_under_edition"
SHEETS_NAMES_KEY = "sheets_names"
FILTER_INPUT_CID = "__filter_input__"
DG_FILTER_INPUT = "filter" # hide or show columns filtering capabilities
DG_TABLE_HEADER = "header" # hide or show the header
DG_TABLE_FOOTER = "footer" # hide or show the footer
DG_ROWS_INDEXES = "row_index" # hide or show the rows indexes (default is False)
DG_READ_ONLY = "read_only" # table in read only mode
DG_COLUMNS = "columns" # entry for columns definitions
DG_SELECTION_MODE = "selection_mode" # select row, column or cell (DG_SELECTION_MODE_CELL)
DG_COLUMNS_REORDERING = "columns_reordering" # to allow or deny columns reordering
DG_STATE_COLUMN_VISIBILITY = "column_visibility"
DG_STATE_ROW_VISIBILITY = "row_visibility"
DG_DATATYPE_NUMBER = "number"
DG_DATATYPE_STRING = "string"
DG_DATATYPE_DATETIME = "datetime"
DG_DATATYPE_BOOL = "bool"
DG_DATATYPE_LIST = "list"
DG_DATATYPE_CHOICE = "choice"
DG_AGGREGATE_SUM = "sum"
DG_AGGREGATE_COUNT = "count"
DG_AGGREGATE_MEAN = "mean"
DG_AGGREGATE_MIN = "min"
DG_AGGREGATE_MAX = "max"
DG_AGGREGATE_FILTERED_SUM = "filtered_sum"
DG_AGGREGATE_FILTERED_COUNT = "filtered_count"
DG_AGGREGATE_FILTERED_MEAN = "filtered_mean"
DG_AGGREGATE_FILTERED_MIN = "filtered_min"
DG_AGGREGATE_FILTERED_MAX = "filtered_max"
DG_SELECTION_MODE_CELL = "cell"
DG_SELECTION_MODE_COLUMN = "column"
DG_SELECTION_MODE_ROW = "row"
DG_SELECTION_MODES = [DG_SELECTION_MODE_CELL, DG_SELECTION_MODE_COLUMN, DG_SELECTION_MODE_ROW]
BADGES_COLORS= [
"#1a237e", # Muted Navy Blue
"#512b2b", # Burgundy Red
"#3b4d3f", # Olive Green
"#674d7d", # Dusty Purple
"#355c57", # Muted Teal
"#533f2e", # Burnt Umber
"#765c48", # Sandy Brown
"#2b3a42", # Steel Blue
"#263238", # Charcoal Grayish Cyan
"#4e4152" # Faded Mauve
]

View File

@@ -0,0 +1,138 @@
from fasthtml.common import *
# fluent ArrowSortDown24Regular
icon_chevron_sort_down_regular = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M11.65 4.007l.1-.007a.75.75 0 0 1 .744.648l.007.102l-.001 12.696l3.22-3.221a.75.75 0 0 1 .976-.073l.084.072a.75.75 0 0 1 .073.977l-.072.084l-4.497 4.5a.75.75 0 0 1-.976.073l-.084-.073l-4.504-4.5a.75.75 0 0 1 .976-1.133l.084.072L11 17.442V4.75a.75.75 0 0 1 .65-.743l.1-.007l-.1.007z" fill="currentColor"></path></g></svg>""")
# fluent ArrowSortDown24Filled
icon_chevron_sort_down_filled = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M11.883 4.01L12 4.005a1 1 0 0 1 .993.883l.007.117v11.584l2.293-2.294a1 1 0 0 1 1.32-.084l.094.083a1 1 0 0 1 .084 1.32l-.084.095l-3.996 4a1 1 0 0 1-1.32.083l-.094-.083l-4.004-4a1 1 0 0 1 1.32-1.498l.094.083L11 16.583V5.004a1 1 0 0 1 .883-.992L12 4.004l-.117.007z" fill="currentColor"></path></g></svg>""")
# fluent ArrowSortUp24Regular
icon_chevron_sort_up_regular = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M6.72 8.715l4.494-4.495a.75.75 0 0 1 .976-.073l.085.072l4.504 4.495a.75.75 0 0 1-.975 1.134l-.084-.072l-3.223-3.217v12.696a.75.75 0 0 1-.648.743l-.101.007a.75.75 0 0 1-.743-.648l-.007-.102l-.001-12.698L7.78 9.775a.75.75 0 0 1-.976.073l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084l4.494-4.495L6.72 8.715z" fill="currentColor"></path></g></svg>""")
# fluent ArrowSortUp24Filled
icon_chevron_sort_up_filled = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M7.293 8.293l3.995-4a1 1 0 0 1 1.32-.084l.094.083l4.006 4a1 1 0 0 1-1.32 1.499l-.094-.083l-2.293-2.291v11.584a1 1 0 0 1-.883.993L12 20a1 1 0 0 1-.993-.884L11 19.001V7.41L8.707 9.707a1 1 0 0 1-1.32.084l-.094-.084a1 1 0 0 1-.084-1.32l.084-.094l3.995-4l-3.995 4z" fill="currentColor"></path></g></svg>""")
# fluent Filter24Regular
icon_filter_regular = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M13.5 16a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h3zm3-5a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1 0-1.5h9zm3-5a.75.75 0 0 1 0 1.5h-15a.75.75 0 0 1 0-1.5h15z" fill="currentColor"></path></g></svg>""")
# fluent Filter24Filled
icon_filter_filled = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M10 16h4a1 1 0 0 1 .117 1.993L14 18h-4a1 1 0 0 1-.117-1.993L10 16h4h-4zm-2-5h8a1 1 0 0 1 .117 1.993L16 13H8a1 1 0 0 1-.117-1.993L8 11h8h-8zM5 6h14a1 1 0 0 1 .117 1.993L19 8H5a1 1 0 0 1-.117-1.993L5 6h14H5z" fill="currentColor"></path></g></svg>""")
# fluent DismissCircle24Regular
icon_dismiss_regular = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2zm0 1.5a8.5 8.5 0 1 0 0 17a8.5 8.5 0 0 0 0-17zm3.446 4.897l.084.073a.75.75 0 0 1 .073.976l-.073.084L13.061 12l2.47 2.47a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L12 13.061l-2.47 2.47a.75.75 0 0 1-.976.072l-.084-.073a.75.75 0 0 1-.073-.976l.073-.084L10.939 12l-2.47-2.47a.75.75 0 0 1-.072-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073L12 10.939l2.47-2.47a.75.75 0 0 1 .976-.072z" fill="currentColor"></path></g></svg>"""
)
# fluent DismissCircle24Filled
icon_dismiss_filled = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2zm3.53 6.47l-.084-.073a.75.75 0 0 0-.882-.007l-.094.08L12 10.939l-2.47-2.47l-.084-.072a.75.75 0 0 0-.882-.007l-.094.08l-.073.084a.75.75 0 0 0-.007.882l.08.094L10.939 12l-2.47 2.47l-.072.084a.75.75 0 0 0-.007.882l.08.094l.084.073a.75.75 0 0 0 .882.007l.094-.08L12 13.061l2.47 2.47l.084.072a.75.75 0 0 0 .882.007l.094-.08l.073-.084a.75.75 0 0 0 .007-.882l-.08-.094L13.061 12l2.47-2.47l.072-.084a.75.75 0 0 0 .007-.882l-.08-.094l-.084-.073l.084.073z" fill="currentColor"></path></g></svg>"""
)
# fluent TextAlignDistributed24Regular
icon_resize_columns_regular = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M5.28 7.22l-.72-.72h16.69a.75.75 0 0 0 0-1.5H4.56l.72-.72a.75.75 0 0 0-1.06-1.06l-2 2a.75.75 0 0 0 0 1.06l2 2a.75.75 0 0 0 1.06-1.06zM2.75 11.5a.75.75 0 0 0 0 1.5h18.5a.75.75 0 0 0 0-1.5H2.75zm0 8h16.69l-.72.72a.75.75 0 1 0 1.06 1.06l2-2a.75.75 0 0 0 0-1.06l-2-2a.75.75 0 1 0-1.06 1.06l.72.72H2.75a.75.75 0 0 0 0 1.5z" fill="currentColor"></path></g></svg>"""
)
# fluent TextAlignDistributed24Regular
icon_resize_columns_filled = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M5.28 7.22l-.72-.72h16.69a.75.75 0 0 0 0-1.5H4.56l.72-.72a.75.75 0 0 0-1.06-1.06l-2 2a.75.75 0 0 0 0 1.06l2 2a.75.75 0 0 0 1.06-1.06zM2.75 11.5a.75.75 0 0 0 0 1.5h18.5a.75.75 0 0 0 0-1.5H2.75zm0 8h16.69l-.72.72a.75.75 0 1 0 1.06 1.06l2-2a.75.75 0 0 0 0-1.06l-2-2a.75.75 0 1 0-1.06 1.06l.72.72H2.75a.75.75 0 0 0 0 1.5z" fill="currentColor"></path></g></svg>"""
)
icon_chevron_sort = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24">
<path d="m12,21.967l-7,-7l1.4,-1.4l5.6,5.6l5.6,-5.6l1.4,1.4l-7,7z" fill="currentColor"></path>
<path d="m12,2l7,7l-1.4,1.4l-5.6,-5.6l-5.6,5.6l-1.4,-1.4l7,-7z" fill="currentColor"></path>
</svg>""")
icon_search = NotStr(
"""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none"><path d="M10 2.5a7.5 7.5 0 0 1 5.964 12.048l4.743 4.745a1 1 0 0 1-1.32 1.497l-.094-.083l-4.745-4.743A7.5 7.5 0 1 1 10 2.5zm0 2a5.5 5.5 0 1 0 0 11a5.5 5.5 0 0 0 0-11z" fill="currentColor"></path></g></svg>""")
# Carbon FilterRemove
icon_filter_remove = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32">
<path d="M30 11.414L28.586 10L24 14.586L19.414 10L18 11.414L22.586 16L18 20.585L19.415 22L24 17.414L28.587 22L30 20.587L25.414 16L30 11.414z" fill="currentColor"/>
<path d="M4 4a2 2 0 0 0-2 2v3.17a2 2 0 0 0 .586 1.415L10 18v8a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2v-2h-2v2h-4v-8.83l-.586-.585L4 9.171V6h20v2h2V6a2 2 0 0 0-2-2z" fill="currentColor"/>
</svg>
""")
# Fluent CheckboxChecked16Regular
icon_checked = NotStr("""<svg name="CheckboxChecked16Regular" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M10.854 6.854a.5.5 0 0 0-.708-.708L7 9.293L5.854 8.146a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3.5-3.5zM3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v7a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 11.5v-7zm8.5-.5h-7a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.5-.5z" fill="currentColor"></path>
</g>
</svg>""")
# Fluent CheckboxUnchecked16Regular
icon_unchecked = NotStr("""<svg name="CheckboxUnchecked16Regular" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M4.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zm0 1h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5z" fill="currentColor"></path>
</g>
</svg>""")
# Fluent TableCellEdit20Regular
icon_cell_selection = NotStr("""<svg name="CellSelectionMode" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M9.985 13c.088-.116.184-.227.288-.331l.669-.669H8V8h4v2.942l1-1V8.001h1.942l.16-.161c.256-.256.549-.454.861-.593A1.99 1.99 0 0 0 15 7H5a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h4.985zM7 8v4H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h2zm8.809.547l-4.83 4.829a2.197 2.197 0 0 0-.577 1.02l-.375 1.498a.89.89 0 0 0 1.079 1.079l1.498-.375a2.197 2.197 0 0 0 1.02-.578l4.83-4.829a1.87 1.87 0 0 0-2.646-2.644z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent ColumnEdit20Regular
column_selection_mode = NotStr("""<svg name="ColumnSelectionMode" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M3 3.5a.5.5 0 0 1 .5-.5H4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-.5a.5.5 0 0 1 0-1H4a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-.5a.5.5 0 0 1-.5-.5zM9 4a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h.475a3.17 3.17 0 0 0-.043.155L9.22 17H9a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6.943l-1 1V5a1 1 0 0 0-1-1H9zm6 1v4.943l-1 1V5a2 2 0 0 1 2-2h.5a.5.5 0 0 1 0 1H16a1 1 0 0 0-1 1zm-4.02 10.377l4.83-4.83a1.87 1.87 0 1 1 2.644 2.646l-4.83 4.829a2.197 2.197 0 0 1-1.02.578l-1.498.374a.89.89 0 0 1-1.079-1.078l.375-1.498c.096-.386.296-.74.578-1.02z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent
column_triple_selection_mode = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M3 4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm3 0H4v12h2V4zm2 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v8.943l-1 1V4H9v12h.475a3.17 3.17 0 0 0-.043.155L9.22 17H9a1 1 0 0 1-1-1V4zm9 0v5.003c-.341.016-.68.092-1 .229V4h-2v6.943l-1 1V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1zm-6.02 11.377l4.83-4.83a1.87 1.87 0 1 1 2.644 2.646l-4.83 4.829a2.197 2.197 0 0 1-1.02.578l-1.498.374a.89.89 0 0 1-1.079-1.078l.375-1.498c.096-.386.296-.74.578-1.02z" fill="currentColor">
</path>
</g>
</svg>""")
# Carbon Row
icon_row_selection = NotStr("""<svg name="RowSelectionMode" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32">
<path d="M4 24h24v2H4z" fill="currentColor"></path>
<path d="M26 18H6v-4h20v4m2 0v-4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2z" fill="currentColor"></path>
<path d="M4 6h24v2H4z" fill="currentColor"></path>
</svg>""")
# Carbon Column
icon_column_selection = NotStr("""<svg name="ColumnSelectionMode" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32">
<path d="M24 4h2v24h-2z" fill="currentColor"></path>
<path d="M18 6v20h-4V6h4m0-2h-4a2 2 0 0 0-2 2v20a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" fill="currentColor"></path>
<path d="M6 4h2v24H6z" fill="currentColor"></path>
</svg>""")
# Fluent EyeOff20Filled # to hide row or columns
icon_hide = NotStr("""<svg name="Hide" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M2.854 2.146a.5.5 0 1 0-.708.708l3.5 3.498a8.097 8.097 0 0 0-3.366 5.046a.5.5 0 1 0 .979.204a7.09 7.09 0 0 1 3.108-4.528L7.95 8.656a3.5 3.5 0 1 0 4.884 4.884l4.313 4.314a.5.5 0 0 0 .708-.708l-15-15zm7.27 5.857l3.363 3.363a3.5 3.5 0 0 0-3.363-3.363zM7.53 5.41l.803.803A6.632 6.632 0 0 1 10 6c3.206 0 6.057 2.327 6.74 5.602a.5.5 0 1 0 .98-.204C16.943 7.673 13.693 5 10 5c-.855 0-1.687.143-2.469.41z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent Eye20Filled # to show columns or rows
icon_show = NotStr("""<svg name="Show" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M3.26 11.602C3.942 8.327 6.793 6 10 6c3.206 0 6.057 2.327 6.74 5.602a.5.5 0 0 0 .98-.204C16.943 7.673 13.693 5 10 5c-3.693 0-6.943 2.673-7.72 6.398a.5.5 0 0 0 .98.204zM9.99 8a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent ArrowMove20Regular
icon_move = NotStr("""<svg name="Move" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M7.146 4.354a.5.5 0 0 0 .708 0L9.5 2.707V6.5a.5.5 0 0 0 1 0V2.707l1.646 1.647a.5.5 0 0 0 .708-.708l-2.5-2.5a.5.5 0 0 0-.708 0l-2.5 2.5a.5.5 0 0 0 0 .708zm-2.792 3.5a.5.5 0 1 0-.708-.708l-2.5 2.5a.5.5 0 0 0 0 .708l2.5 2.5a.5.5 0 0 0 .708-.708L2.707 10.5H6.5a.5.5 0 0 0 0-1H2.707l1.647-1.646zm11.292 0a.5.5 0 0 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708l1.647-1.646H13.5a.5.5 0 0 1 0-1h3.793l-1.647-1.646zm-7.792 7.792a.5.5 0 0 0-.708.708l2.5 2.5a.5.5 0 0 0 .708 0l2.5-2.5a.5.5 0 0 0-.708-.708L10.5 17.293V13.5a.5.5 0 0 0-1 0v3.793l-1.646-1.647z" fill="currentColor">
</path>
</g>
</svg>""")

View File

@@ -0,0 +1,131 @@
import json
import logging
from fasthtml.fastapp import fast_app
from starlette.datastructures import UploadFile
from components.datagrid_new.components.FilterAll import FilterAll
from components.datagrid_new.constants import Routes, ADD_NEW_VIEW
from core.instance_manager import InstanceManager, debug_session
datagrid_new_app, rt = fast_app()
logger = logging.getLogger("DataGrid")
@rt(Routes.Download)
def get(session, _id: str):
logger.debug(f"Entering {Routes.Download} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.toggle_file_upload()
@rt(Routes.Settings)
def get(session, _id: str):
logger.debug(f"Entering {Routes.Settings} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.toggle_settings()
@rt(Routes.Upload)
async def post(session, _id: str, file: UploadFile = None):
file_name = file.filename if file is not None else None
logger.debug(f"Entering {Routes.Upload} with args {debug_session(session)}, {_id=}, {file_name=}")
instance = InstanceManager.get(session, _id)
if file is not None:
file_content = await file.read()
return instance.upload_excel_file(file_name, file_content)
else:
return instance.error_message("No file uploaded")
@rt(Routes.UpdateFromExcel)
def post(session, _id: str, file: UploadFile = None, sheet_name: str = None):
logger.debug(f"Entering {Routes.UpdateFromExcel} with args {debug_session(session)}, {_id=}")
instance = InstanceManager.get(session, _id)
return instance.on_file_upload(file, sheet_name)
@rt(Routes.UpdateColumns)
def post(session, _id: str, updates: str = None):
logger.debug(f"Entering {Routes.UpdateColumns} with args {debug_session(session)}, {_id=}, {updates=}")
instance = InstanceManager.get(session, _id)
return instance.update_columns_state(json.loads(updates))
@rt(Routes.Filter)
def post(session, _id: str, col_id: str, f: str):
"""
Filter the content of a column using checkboxes
:param session:
:param _id:
:param col_id:
:param f:
:return:
"""
try:
logger.debug(f"Entering filter with args {_id=}, {col_id=}, {f=}")
instance = InstanceManager.get(session, _id)
if isinstance(instance, FilterAll):
return instance.filter(f)
value = json.loads(f)
return instance.filter(col_id, value) # instance is DataGrid
except Exception as ex:
logger.error(ex)
return None
@rt(Routes.ResetFilter)
def post(session, _id: str, col_id: str):
"""
Reset the Filter all
:param session:
:param _id:
:param col_id:
:return:
"""
logger.debug(f"Entering reset filter with args {_id=}, {col_id=}")
instance = InstanceManager.get(session, _id)
if isinstance(instance, FilterAll):
return instance.reset()
return instance.reset_filter(col_id)
@rt(Routes.ChangeView)
def post(session, _id: str, view_name: str):
logger.debug(f"Entering change_view with args {_id=}, {view_name=}")
instance = InstanceManager.get(session, _id)
if view_name == ADD_NEW_VIEW:
return instance.render_create_view()
return instance.change_view(view_name if view_name not in ("", "None") else None)
@rt(Routes.AddView)
def post(session, _id: str, view_name: str):
logger.debug(f"Entering add_view with args {_id=}, {view_name=}")
instance = InstanceManager.get(session, _id)
return instance.add_view(view_name)
@rt(Routes.UpdateView)
def post(session, _id: str, view_name: str):
logger.debug(f"Entering update_view with args {_id=}, {view_name=}")
instance = InstanceManager.get(session, _id)
return instance.update_view(view_name)
@rt(Routes.OnKeyPressed)
def post(session, _id: str, key: str, arg: str = None):
logger.debug(f"Entering on_key_pressed with args {_id=}, {key=}, {arg=}")
instance = InstanceManager.get(session, _id)
return instance.manage_key_pressed(key, arg)
@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=}")
instance = InstanceManager.get(session, _id)
return instance.manage_click(cell_id, modifier)

View File

@@ -0,0 +1,19 @@
# id
**Datagrid ids**:
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}` |

View File

View File

@@ -0,0 +1,325 @@
input:focus {
outline: none;
}
.dt2-drag-drop {
display: none;
position: absolute;
top: 100%;
z-index: 5;
width: 100px;
border: 1px solid var(--color-base-300);
border-radius: 10px;
padding: 10px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: var(--color-base-100);
box-sizing: border-box;
overflow-x: auto;
pointer-events: none; /* Prevent interfering with mouse events */
}
.dt2-main {
position: relative;
height: 100%;
}
.dt2-sidebar {
opacity: 0; /* Default to invisible */
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: absolute;
top: 0;
right: 0;
width: 75%;
max-height: 710px;
overflow-y: auto;
background-color: var(--color-base-100);
z-index: var(--datagrid-sidebar-zindex);
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */
border-radius: 10px;
}
.dt2-sidebar.active {
opacity: 1;
visibility: visible;
transition: opacity 0.3s ease;
}
.dt2-scrollbars {
bottom: 0;
left: 0;
pointer-events: none; /* Ensures parents don't intercept pointer events */
position: absolute;
right: 0;
top: 0;
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 */
.dt2-scrollbars-vertical-wrapper,
.dt2-scrollbars-horizontal-wrapper {
opacity: 1;
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
pointer-events: auto; /* Allow interaction */
}
/* Scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: var(--color-resize);
border-radius: 3px;
pointer-events: auto; /* Allow interaction with the scrollbar */
cursor: pointer;
}
/* Scrollbar hover effects */
.dt2-scrollbars-vertical:hover,
.dt2-scrollbars-horizontal:hover,
.dt2-scrollbars-vertical.dt2-dragging,
.dt2-scrollbars-horizontal.dt2-dragging {
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);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.dt2-table:focus {
outline: none;
}
.dt2-header,
.dt2-footer {
background-color: var(--color-base-200);
border-radius: 10px 10px 0 0;
min-width: max-content;
}
.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;
}
.dt2-row {
display: flex;
width: 100%;
height: 22px;
}
.dt2-cell {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 2px 8px;
position: relative;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 100px;
flex-grow: 0;
flex-shrink: 1;
box-sizing: border-box; /* to include the borders in the computations */
border-bottom: 1px solid var(--color-border);
user-select: none;
}
.dt2-cell-content-text {
text-align: inherit;
width: 100%;
padding-right: 10px;
}
.dt2-cell-content-checkbox {
display: flex;
width: 100%;
justify-content: center; /* Horizontally center the icon */
align-items: center; /* Vertically center the icon */
}
.dt2-cell-content-number {
text-align: right;
width: 100%;
padding-right: 10px;
}
.dt2-resize-handle {
position: absolute;
right: 0;
top: 0;
width: 8px;
height: 100%;
cursor: col-resize;
}
.dt2-resize-handle::after {
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
display: block; /* Makes it a block element */
width: 3px;
height: 60%;
top: calc(50% - 60% * 0.5);
background-color: var(--color-resize);
}
.dt2-header-hidden {
width: 5px;
background: var(--color-neutral-content);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.dt2-col-hidden {
width: 5px;
border-bottom: 1px solid var(--color-border);
}
.dt2-highlight-1 {
color: var(--color-accent);
}
.dt2-item-handle {
background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0);
background-repeat: repeat;
background-size: 4px 4px;
cursor: grab;
display: inline-block;
height: 16px;
margin: auto;
position: relative;
top: 1px;
width: 12px;
}
/* **************************************************************************** */
/* COLUMNS SETTINGS */
/* **************************************************************************** */
.dt2-cs-header {
background-color: var(--color-base-200);
min-width: max-content;
}
.dt2-cs-columns {
display: grid;
grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr;
}
.dt2-cs-body input {
outline: none;
border-color: transparent;
box-shadow: none;
}
.dt2-cs-body input[type="checkbox"],
.dt2-cs-body input.checkbox {
outline: initial;
border-color: var(--color-border);
}
.dt2-cs-cell {
padding: 0 6px 0 6px;
margin: auto;
}
.dt2-cs-checkbox-cell {
margin: auto;
}
.dt2-cs-number-cell {
padding: 0 6px 0 6px;
text-align: right;
}
.dt2-cs-select-cell {
padding: 0 6px;
margin: 3px 0;
}
.dt2-cs-body input:hover {
border: 1px solid #ccc; /* Provide a subtle border on focus */
}
.dt2-views-container-select {
width: 170px;
}
.dt2-views-container-create {
width: 300px;
}
/*.dt2-drag-drop {*/
/* display: none;*/
/* position: absolute;*/
/* top: 100%;*/
/* z-index: 5;*/
/* width: 100px;*/
/* border: 1px solid var(--color-border);*/
/* border-radius: 10px;*/
/* padding: 10px;*/
/* box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);*/
/* background: oklch(var(--b1));*/
/* box-sizing: border-box;*/
/* overflow-x: auto;*/
/* pointer-events: none; !* Prevent interfering with mouse events *!*/
/*}*/

View File

@@ -0,0 +1,395 @@
function bindDatagrid(datagridId, allowColumnsReordering) {
bindTooltipsWithDelegation(datagridId);
bindScrollbars(datagridId);
makeResizable(datagridId)
}
function bindScrollbars(datagridId) {
console.debug("bindScrollbars on element " + datagridId);
const datagrid = document.getElementById(datagridId);
if (!datagrid) {
console.error(`Datagrid with id "${datagridId}" not found.`);
return;
}
const verticalScrollbar = datagrid.querySelector(".dt2-scrollbars-vertical");
const verticalWrapper = datagrid.querySelector(".dt2-scrollbars-vertical-wrapper");
const horizontalScrollbar = datagrid.querySelector(".dt2-scrollbars-horizontal");
const horizontalWrapper = datagrid.querySelector(".dt2-scrollbars-horizontal-wrapper");
const body = datagrid.querySelector(".dt2-body");
const table = datagrid.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !body || !table) {
console.error("Essential scrollbar or content elements are missing in the datagrid.");
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";
};
const computeScrollbarSize = () => {
// Vertical scrollbar height
const visibleHeight = body.clientHeight;
const totalHeight = body.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
if (totalHeight > 0) {
const scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
verticalScrollbar.style.height = `${scrollbarHeight}px`;
} else {
verticalScrollbar.style.height = `0px`;
}
// Horizontal scrollbar width
const visibleWidth = table.clientWidth;
const totalWidth = table.scrollWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
if (totalWidth > 0) {
const scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
} else {
horizontalScrollbar.style.width = `0px`;
}
};
const updateVerticalScrollbarPosition = () => {
const maxScrollTop = body.scrollHeight - body.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
if (maxScrollTop > 0) {
const scrollRatio = wrapperHeight / body.scrollHeight;
verticalScrollbar.style.top = `${body.scrollTop * scrollRatio}px`;
}
};
const addDragEvent = (scrollbar, updateFunction) => {
let isDragging = false;
let startY = 0;
let startX = 0;
scrollbar.addEventListener("mousedown", (e) => {
isDragging = true;
startY = e.clientY;
startX = e.clientX;
document.body.style.userSelect = "none"; // Disable text selection while dragging scrollbars
scrollbar.classList.add("dt2-dragging");
});
document.addEventListener("mousemove", (e) => {
if (isDragging) {
const deltaY = e.clientY - startY;
const deltaX = e.clientX - startX;
updateFunction(deltaX, deltaY);
// Reset start points for next update
startY = e.clientY;
startX = e.clientX;
}
});
document.addEventListener("mouseup", () => {
isDragging = false;
document.body.style.userSelect = ""; // Re-enable text selection
scrollbar.classList.remove("dt2-dragging");
});
};
const updateVerticalScrollbar = (deltaX, deltaY) => {
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = body.scrollHeight - body.clientHeight;
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;
};
const updateHorizontalScrollbar = (deltaX, deltaY) => {
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
const maxScrollLeft = table.scrollWidth - table.clientWidth;
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;
};
const handleWheelScrolling = (event) => {
const deltaX = event.deltaX;
const deltaY = event.deltaY;
// Scroll the body and table content
body.scrollTop += deltaY; // Vertical scrolling
table.scrollLeft += deltaX; // Horizontal scrolling
// Update the vertical scrollbar position
updateVerticalScrollbarPosition();
// Prevent default behavior to fully manage the scroll
event.preventDefault();
};
addDragEvent(verticalScrollbar, updateVerticalScrollbar);
addDragEvent(horizontalScrollbar, updateHorizontalScrollbar);
body.addEventListener("wheel", handleWheelScrolling);
// Initialize scrollbars
computeScrollbarVisibility();
computeScrollbarSize();
// Recompute on window resize
window.addEventListener("resize", () => {
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarPosition();
});
}
function makeResizable(datagridId) {
console.debug("makeResizable on element " + datagridId);
const tableId = 't_' + datagridId;
const table = document.getElementById(tableId);
const resizeHandles = table.querySelectorAll('.dt2-resize-handle');
const MIN_WIDTH = 30; // Prevent columns from becoming too narrow
// Attach event listeners using delegation
resizeHandles.forEach(handle => {
handle.addEventListener('mousedown', onStartResize);
handle.addEventListener('touchstart', onStartResize, {passive: false});
handle.addEventListener('dblclick', onDoubleClick); // Reset column width
});
let resizingState = null; // Maintain resizing state information
function onStartResize(event) {
event.preventDefault(); // Prevent unintended selections
const isTouch = event.type === 'touchstart';
const startX = isTouch ? event.touches[0].pageX : event.pageX;
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Store initial state
const startWidth = cell.offsetWidth + 8;
resizingState = {startX, startWidth, colIndex, cells};
// Attach event listeners for resizing
document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onResize);
document.addEventListener(isTouch ? 'touchend' : 'mouseup', onStopResize);
}
function onResize(event) {
if (!resizingState) {
return;
}
const isTouch = event.type === 'touchmove';
const currentX = isTouch ? event.touches[0].pageX : event.pageX;
const {startX, startWidth, cells} = resizingState;
// Calculate new width and apply constraints
const newWidth = Math.max(MIN_WIDTH, startWidth + (currentX - startX));
cells.forEach(cell => {
cell.style.width = `${newWidth}px`;
});
}
function onStopResize(event) {
if (!resizingState) {
return;
}
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', {
detail: {colIndex, newWidth: finalWidth + 'px'},
});
table.dispatchEvent(resizeEvent);
// Clean up
resizingState = null;
document.removeEventListener('mousemove', onResize);
document.removeEventListener('mouseup', onStopResize);
document.removeEventListener('touchmove', onResize);
document.removeEventListener('touchend', onStopResize);
}
function onDoubleClick(event) {
const handle = event.target;
const cell = handle.parentElement;
const colIndex = cell.getAttribute('data-col');
const cells = table.querySelectorAll(`.dt2-cell[data-col="${colIndex}"]`);
// Reset column width
cells.forEach(cell => {
cell.style.width = ''; // Use CSS default width
});
// Emit reset event
const resetEvent = new CustomEvent('columnReset', {detail: {colIndex}});
table.dispatchEvent(resetEvent);
}
}
function bindColumnsSettings(datagridId) {
console.debug("bindColumnsSettings on element " + datagridId);
const datagrid = document.querySelector(`#${datagridId}`);
if (!datagrid) {
console.error(`Datagrid with ID "${datagridId}" not found.`);
return;
}
// Target only dt2-cs-row elements inside dt2-cs-body
const rows = datagrid.querySelectorAll('.dt2-cs-row');
rows.forEach((row) => {
const handle = row.querySelector('.dt2-item-handle');
handle.setAttribute('draggable', 'true'); // Make the handle draggable
handle.addEventListener('dragstart', (e) => onDragStart(e, row));
row.addEventListener('dragover', onDragOver); // Added to target row cells as well
row.addEventListener('drop', (e) => onDrop(e, row));
});
let draggedRow = null;
function onDragStart(event, row) {
draggedRow = row; // Save the dragged row
// Add a class to highlight the dragged row
setTimeout(() => row.classList.add('dragging'), 0);
}
function onDragOver(event) {
event.preventDefault(); // Allow dropping
const container = datagrid.querySelector('.dt2-cs-body');
const targetRow = event.target.closest('.dt2-cs-row'); // Ensure we are working with rows
if (targetRow && targetRow !== draggedRow) {
const bounding = targetRow.getBoundingClientRect();
const offset = event.clientY - bounding.top - bounding.height / 2;
// Reorder rows based on the cursor position
if (offset > 0) {
container.insertBefore(draggedRow, targetRow.nextSibling);
} else {
container.insertBefore(draggedRow, targetRow);
}
}
}
function onDrop(event) {
event.preventDefault(); // Prevent default behavior
// Clean the dragging styles and reset
if (draggedRow) {
draggedRow.classList.remove('dragging');
draggedRow = null;
}
}
}
function getColumnsDefinitions(columnsSettingsId) {
console.debug("getColumnsDefinitions on element " + columnsSettingsId);
// Select the container element that holds all rows
const container = document.querySelector(`#${columnsSettingsId}`);
if (!container) {
console.error(`Container with id '${columnsSettingsId}' not found.`);
return JSON.stringify([]);
}
// Find all rows inside the container
const rows = container.querySelectorAll(".dt2-cs-row");
const result = [];
rows.forEach(row => {
// Extract column-specific data
const colId = row.getAttribute("data-col").trim();
const title = row.querySelector(`input[name="title_${colId}"]`)?.value || "";
const type = row.querySelector(`select[name="type_${colId}"]`)?.value || "";
const visible = row.querySelector(`input[name="visible_${colId}"]`)?.checked || false;
const usable = row.querySelector(`input[name="usable_${colId}"]`)?.checked || false;
const width = row.querySelector(`input[name="width_${colId}"]`)?.value || "";
// Push the row data into the result array
result.push({
col_id: colId,
title: title.trim(),
type: type.trim(),
visible: visible,
usable: usable,
width: parseInt(width, 10) || 0
});
});
return JSON.stringify(result); // Convert to JSON string
}
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
}
const parentElement = findParentByName(event.target, 'dt-body-cell')
return parentElement ? parentElement.id : null;
}
function getClickModifier(event) {
if (event instanceof PointerEvent) {
let res = "";
// Detect AltGr specifically
const isAltGr = event.ctrlKey && event.altKey && !event.shiftKey && event.code === "AltRight";
if (!isAltGr) {
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-" }
return res;
}
return null;
}

View File

@@ -0,0 +1,56 @@
# Fluent ArrowMove20Regular
from fastcore.basics import NotStr
icon_move = NotStr("""<svg name="Move" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M7.146 4.354a.5.5 0 0 0 .708 0L9.5 2.707V6.5a.5.5 0 0 0 1 0V2.707l1.646 1.647a.5.5 0 0 0 .708-.708l-2.5-2.5a.5.5 0 0 0-.708 0l-2.5 2.5a.5.5 0 0 0 0 .708zm-2.792 3.5a.5.5 0 1 0-.708-.708l-2.5 2.5a.5.5 0 0 0 0 .708l2.5 2.5a.5.5 0 0 0 .708-.708L2.707 10.5H6.5a.5.5 0 0 0 0-1H2.707l1.647-1.646zm11.292 0a.5.5 0 0 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708l1.647-1.646H13.5a.5.5 0 0 1 0-1h3.793l-1.647-1.646zm-7.792 7.792a.5.5 0 0 0-.708.708l2.5 2.5a.5.5 0 0 0 .708 0l2.5-2.5a.5.5 0 0 0-.708-.708L10.5 17.293V13.5a.5.5 0 0 0-1 0v3.793l-1.646-1.647z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent Settings16Regular
icon_settings = NotStr("""<svg name="settings" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M8 6a2 2 0 1 0 0 4a2 2 0 0 0 0-4zM7 8a1 1 0 1 1 2 0a1 1 0 0 1-2 0zm3.618-3.602a.708.708 0 0 1-.824-.567l-.26-1.416a.354.354 0 0 0-.275-.282a6.072 6.072 0 0 0-2.519 0a.354.354 0 0 0-.275.282l-.259 1.416a.71.71 0 0 1-.936.538l-1.359-.484a.355.355 0 0 0-.382.095c-.569.627-1 1.367-1.262 2.173a.352.352 0 0 0 .108.378l1.102.931a.704.704 0 0 1 0 1.076l-1.102.931a.352.352 0 0 0-.108.378A5.986 5.986 0 0 0 3.53 12.02a.355.355 0 0 0 .382.095l1.36-.484a.708.708 0 0 1 .936.538l.258 1.416c.026.14.135.252.275.281a6.075 6.075 0 0 0 2.52 0a.353.353 0 0 0 .274-.281l.26-1.416a.71.71 0 0 1 .936-.538l1.359.484c.135.048.286.01.382-.095c.569-.627 1-1.367 1.262-2.173a.352.352 0 0 0-.108-.378l-1.102-.931a.703.703 0 0 1 0-1.076l1.102-.931a.352.352 0 0 0 .108-.378A5.985 5.985 0 0 0 12.47 3.98a.355.355 0 0 0-.382-.095l-1.36.484a.71.71 0 0 1-.111.03zm-6.62.58l.937.333a1.71 1.71 0 0 0 2.255-1.3l.177-.97a5.105 5.105 0 0 1 1.265 0l.178.97a1.708 1.708 0 0 0 2.255 1.3L12 4.977c.255.334.467.698.63 1.084l-.754.637a1.704 1.704 0 0 0 0 2.604l.755.637a4.99 4.99 0 0 1-.63 1.084l-.937-.334a1.71 1.71 0 0 0-2.255 1.3l-.178.97a5.099 5.099 0 0 1-1.265 0l-.177-.97a1.708 1.708 0 0 0-2.255-1.3L4 11.023a4.987 4.987 0 0 1-.63-1.084l.754-.638a1.704 1.704 0 0 0 0-2.603l-.755-.637c.164-.386.376-.75.63-1.084z" fill="currentColor">
</path>
</g>
</svg>
""")
# Fluent FolderOpen20Regular
icon_open = NotStr("""<svg name="open" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M16.996 7.073V7a2.5 2.5 0 0 0-2.5-2.5H9.664l-1.6-1.2a1.5 1.5 0 0 0-.9-.3H4.5A2.5 2.5 0 0 0 2 5.5l.001 8.998a2.5 2.5 0 0 0 2.201 2.482c.085.014.172.022.26.022H15.18a1.5 1.5 0 0 0 1.472-1.214l1.358-7a1.501 1.501 0 0 0-1.014-1.715zM4.5 4h2.664a.5.5 0 0 1 .3.1l1.734 1.3a.5.5 0 0 0 .3.1h4.998a1.5 1.5 0 0 1 1.5 1.5v.002H5.824a1.5 1.5 0 0 0-1.472 1.214l-1.298 6.676A1.502 1.502 0 0 1 3 14.498L3 5.5A1.5 1.5 0 0 1 4.5 4zm.833 4.407a.5.5 0 0 1 .491-.405h10.713a.5.5 0 0 1 .491.595l-1.357 7a.5.5 0 0 1-.491.405H4.463a.5.5 0 0 1-.49-.595l1.36-7z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent CheckboxChecked16Regular
icon_checked = NotStr("""<svg name="checked" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M10.854 6.854a.5.5 0 0 0-.708-.708L7 9.293L5.854 8.146a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3.5-3.5zM3 4.5A1.5 1.5 0 0 1 4.5 3h7A1.5 1.5 0 0 1 13 4.5v7a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 11.5v-7zm8.5-.5h-7a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.5-.5z" fill="currentColor"></path>
</g>
</svg>""")
# Fluent CheckboxUnchecked16Regular
icon_unchecked = NotStr("""<svg name="unchecked" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16">
<g fill="none">
<path d="M4.5 3A1.5 1.5 0 0 0 3 4.5v7A1.5 1.5 0 0 0 4.5 13h7a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 11.5 3h-7zm0 1h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5z" fill="currentColor"></path>
</g>
</svg>""")
# Fluent Save20Regular
icon_save = NotStr("""<svg name="save" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M3 5a2 2 0 0 1 2-2h8.379a2 2 0 0 1 1.414.586l1.621 1.621A2 2 0 0 1 17 6.621V15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5zm2-1a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1v-4.5A1.5 1.5 0 0 1 6.5 10h7a1.5 1.5 0 0 1 1.5 1.5V16a1 1 0 0 0 1-1V6.621a1 1 0 0 0-.293-.707l-1.621-1.621A1 1 0 0 0 13.379 4H13v2.5A1.5 1.5 0 0 1 11.5 8h-4A1.5 1.5 0 0 1 6 6.5V4H5zm2 0v2.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5V4H7zm7 12v-4.5a.5.5 0 0 0-.5-.5h-7a.5.5 0 0 0-.5.5V16h8z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent Save20Filled
icon_save_filled = NotStr("""<svg name="save-filled" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M3 5a2 2 0 0 1 2-2h1v3.5A1.5 1.5 0 0 0 7.5 8h4A1.5 1.5 0 0 0 13 6.5V3h.379a2 2 0 0 1 1.414.586l1.621 1.621A2 2 0 0 1 17 6.621V15a2 2 0 0 1-2 2v-5.5a1.5 1.5 0 0 0-1.5-1.5h-7A1.5 1.5 0 0 0 5 11.5V17a2 2 0 0 1-2-2V5zm9-2H7v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5V3zm2 8.5V17H6v-5.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5z" fill="currentColor">
</path>
</g>
</svg>""")

View File

@@ -0,0 +1,102 @@
import logging
from fasthtml.components import *
from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ColumnType
from components_helpers import mk_dialog_buttons
from core.utils import get_unique_id
logger = logging.getLogger("ColumnsSettings")
class ColumnsSettings(BaseComponent):
def __init__(self, session, instance_id, owner):
super().__init__(session, instance_id)
self._owner = owner
def _mk_table_header(self):
return Div(
Div(cls="place-self-center"),
Div("Title", cls=" place-self-center"),
Div("Type", cls=" place-self-center"),
Div("Visible", cls=" place-self-center"),
Div("Usable", cls=" place-self-center"),
Div("Width", cls=" place-self-center"),
cls="dt2-cs-header dt2-cs-columns"
)
def _mk_table_body(self):
def _mk_option(option, selected_value):
if selected_value == option:
return Option(option.value, selected=True)
else:
return Option(option.value)
def _mk_columns(columns):
if columns is None:
return []
return [
Div(
A(cls="dt2-item-handle"),
Input(name=f"title_{col_state.col_id}",
type="input",
cls="dt2-cs-cell input",
value=col_state.title),
Select(
*[_mk_option(value, col_state.type) for value in ColumnType],
name=f"type_{col_state.col_id}",
cls="dt2-cs-select-cell select",
),
Input(name=f"visible_{col_state.col_id}",
type="checkbox",
cls="dt2-cs-checkbox-cell toggle toggle-sm",
checked=col_state.visible),
Input(name=f"usable_{col_state.col_id}",
type="checkbox",
cls="dt2-cs-checkbox-cell toggle toggle-sm",
checked=col_state.usable),
Input(name=f"width_{col_state.col_id}",
type="input",
cls="dt2-cs-number-cell input",
value=col_state.width),
cls="dt2-cs-row dt2-cs-columns",
data_col=col_state.col_id,
)
for col_state in self._owner.get_state().columns
]
return Div(*_mk_columns(self._owner.get_state().columns), cls="dt2-cs-body")
def __ft__(self):
on_ok = self._owner.commands.update_columns_settings(self)
on_cancel = self._owner.commands.cancel()
return Div(
Div(
H1("Columns Settings", cls="mb-3 text-xl place-self-center"),
Div(self._mk_table_header(),
self._mk_table_body(),
mk_dialog_buttons(ok_title="Apply", on_ok=on_ok, on_cancel=on_cancel),
cls="dt2-cs-container"),
cls="p-5",
),
Script(f"bindColumnsSettings('{self._id}');"),
id=f"{self._id}",
)
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if prefix is None:
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id(prefix)
return f"{prefix}{suffix}"

View File

@@ -0,0 +1,636 @@
import copy
import logging
from io import BytesIO
from typing import Literal
import pandas as pd
from fasthtml.components import *
from fasthtml.xtend import Script
from pandas import DataFrame
from components.BaseComponent import BaseComponent
from components.datagrid_new.assets.icons import icon_move, icon_open, icon_settings, icon_checked, icon_unchecked
from components.datagrid_new.components.ColumnsSettings import ColumnsSettings
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, \
DataGridFooterConf, DataGridState, DataGridSettings, DatagridView
from components_helpers import mk_icon, mk_ellipsis, mk_tooltip_container
from core.instance_manager import InstanceManager
from core.utils import get_unique_id, make_column_id
logger = logging.getLogger("DataGrid")
class DataGrid(BaseComponent):
def __init__(self, session, _id: str = None, key: str = None, settings_manager=None):
super().__init__(session, _id)
self.commands = DataGridCommandManager(self)
self._key = key
self._settings_manager = settings_manager
self._db = DataGridDatabaseManager(session, settings_manager, key)
self._state: DataGridState = self._db.load_state()
self._settings: DataGridSettings = self._db.load_settings()
self._df: DataFrame | None = self._db.load_dataframe()
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
self.close_sidebar()
def init_from_excel(self):
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)
def init_from_dataframe(self, df: DataFrame):
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_float_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_bool_dtype(dtype):
return ColumnType.Bool
elif pd.api.types.is_datetime64_any_dtype(dtype):
return ColumnType.Datetime
else:
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._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
self._state.columns = [DataGridColumnState(make_column_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_column_id(col_id)].dtype))
for col_index, col_id in enumerate(df.columns)]
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"):
"""
Updates the state of table columns based on the provided updates. Depending on the mode
selected, it either applies incremental changes to existing states or replaces the
current state entirely with new data. The method saves the updated state to the
database and returns updated UI elements after processing.
:param updates: A list of dictionaries, where each dictionary represents an individual
column update. Each dictionary must include the `col_id` key, and can optionally
include any of the following keys: `title`, `visible`, `width`, `type`. If None is
passed, no updates are performed, and the current state is returned without changes.
:param mode: A string defining how updates should be applied. The value "delta" specifies
incremental updates to the existing state, while other values replace the state
entirely with new data. Defaults to "delta" other is "replace".
:return: A tuple containing the updated table interface (from `mk_table`) and the result
of `close_sidebar`.
"""
def _update_column_def(_col_def, _update):
if "title" in _update:
_col_def.title = _update["title"]
if "visible" in _update:
_col_def.visible = _update["visible"]
if "usable" in _update:
_col_def.usable = _update["usable"]
if "width" in _update:
_col_def.width = _update["width"]
if "type" in _update:
_col_def.type = ColumnType(_update["type"])
def _get_or_create_col_def(_col_id):
_new_column = False
try:
_col_def = next((col for col in self._state.columns if col.col_id == col_id))
except StopIteration:
_add_column_to_dataframe_if_needed(_col_id)
_index = self._df.columns.get_loc(col_id)
_col_def = DataGridColumnState(col_id, _index)
self._state.columns.append(_col_def)
_new_column = True
return _col_def, _new_column
def _add_column_to_dataframe_if_needed(_col_id):
if _col_id not in self._df.columns:
self._df[_col_id] = None
return True
return False
new_column = False
if updates is None:
return self.mk_table()
if mode == "delta":
for update in updates:
col_id = update["col_id"]
col_def, temp_new_column = _get_or_create_col_def(col_id)
_update_column_def(col_def, update)
new_column |= temp_new_column
else:
new_columns_states = []
for update in updates:
col_id = update["col_id"]
new_column |= _add_column_to_dataframe_if_needed(col_id)
col_index = self._df.columns.get_loc(col_id)
col_def = DataGridColumnState(col_id, col_index)
_update_column_def(col_def, update)
new_columns_states.append(col_def)
self._state.columns = new_columns_states
self._views.recompute_need_save()
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)
def add_view(self, view_name, columns: list[DataGridColumnState]):
if view_name in [v.name for v in self._settings.views]:
raise ValueError(f"View '{view_name}' already exists")
new = DatagridView(view_name, ViewType.Table, copy.deepcopy(columns))
self._settings.views.append(new)
self._state.selected_view = view_name
self._db.save_all(settings=self._settings, state=self._state)
return self.mk_table()
def update_view(self, view_name, columns: list[DataGridColumnState]):
view = self.get_view(view_name)
if view is None:
raise ValueError(f"View '{view_name}' does not exist")
view.columns = copy.deepcopy(columns)
self._db.save_settings(self._settings)
return self.mk_table()
def change_view(self, view_name):
view = self.get_view(view_name)
if view is not None:
self._state.columns = copy.deepcopy(view.columns)
self._state.selected_view = view_name
return self.mk_table()
def filter(self, column_id: str, filtering_values: str | list[str]):
"""
:param column_id:
:param filtering_values:
:return:
"""
if filtering_values is None:
if column_id in self._state.filtered:
del self._state.filtered[column_id]
else:
self._state.filtered[column_id] = filtering_values
return self.mk_table()
def finalize_interaction(self, new_pos=None, new_input_element=None):
res = []
state = self._state
# reset sidebar if opened
self._state.sidebar_visible = False
res.append(self.mk_sidebar(None, self._state.sidebar_visible, oob=True))
# manage the selection
select_manager = self.mk_selection_manager(new_pos)
res.append(select_manager)
return res
def navigate(self, key: str | None):
pass
def escape(self):
"""
What to do if the escape key is pressed
:return: Cell position to select if any, else None
"""
logger.debug("Calling escape")
if self._state.sidebar_visible:
logger.debug(" escape - Sidebar will be reset")
return self._state.selection.selected
# if self._state.filter_popup_id is not None:
# logger.debug(" escape - Reset filter popup")
# return self._state.selected
#
# if self._state.under_edition:
# logger.debug(" escape - Reset under edition")
# self._update_under_edition(None)
# return self._state.selected
#
# logger.debug(" escape - Reset all")
# if self._state.selected is not None:
# self._state.last_selected = self._state.selected
# self._state.selected = None
# self._state.extra_selected.clear()
# return None
def manage_key_pressed(self, key, value):
if key == "Escape":
new_pos = self.escape()
return self.finalize_interaction(new_pos)
# make sure to return the selection manager
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=''):
new_pos = self.escape()
return self.finalize_interaction(new_pos)
def get_state(self) -> DataGridState:
return self._state
def get_settings(self) -> DataGridSettings:
return self._settings
def get_table_id(self):
return f"t_{self._id}"
def get_view(self, view_name: str = None) -> DatagridView | None:
if view_name is None:
view_name = self._state.selected_view
try:
return next(view for view in self._settings.views if view.name == view_name)
except StopIteration:
return None
def mk_scrollbars(self):
return Div(
Div(Div(cls='dt2-scrollbars-vertical'), cls='dt2-scrollbars-vertical-wrapper'),
Div(Div(cls='dt2-scrollbars-horizontal'), cls='dt2-scrollbars-horizontal-wrapper'),
cls='dt2-scrollbars',
id=f"scb_{self._id}",
)
def mk_table(self, oob=False):
htmx_extra_params = {
"hx-on::after-settle": f"bindDatagrid('{self._id}', true);",
# "hx-on::before-request": "onCellEdition(event);",
}
def _mk_keyboard_management():
return Div(
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='ArrowUp'] from:.dt2-table",
hx_vals=f'{{"_id": "{self._id}", "key":"ArrowUp"}}',
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='ArrowDown'] from:.dt2-table",
hx_vals=f'{{"_id": "{self._id}", "key":"ArrowDown"}}',
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='ArrowLeft'] from:.dt2-table",
hx_vals=f'{{"_id": "{self._id}", "key":"ArrowLeft"}}',
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='ArrowRight'] from:.dt2-table",
hx_vals=f'{{"_id": "{self._id}", "key":"ArrowRight"}}',
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keyup[key=='Escape'] from:.dl-main",
hx_vals=f'{{"_id": "{self._id}", "key":"Escape"}}',
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='Enter'] from:.dt2-table",
hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
Span(hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_trigger=f"keydown[key=='Tab'] from:.dt2-table",
hx_vals=f"js:{{...generateKeyEventPayload('{self._id}', event)}}",
hx_target=f"#tsm_{self._id}",
hx_swap="outerHTML"),
),
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}"),
_mk_keyboard_management(),
Div(
self.mk_scrollbars(),
self.mk_table_header(),
self.mk_table_body(),
self.mk_table_footer(),
cls="dt2-inner-table"),
cls="dt2-table",
tabindex="1",
id=self.get_table_id(),
hx_swap_oob='true' if oob else None,
**htmx_extra_params,
)
def mk_table_header(self):
def _mk_header_name(col_def: DataGridColumnState):
return Div(
mk_ellipsis(col_def.title, name="dt2-header-title"),
cls="flex truncate cursor-default",
)
def _mk_header(col_def: DataGridColumnState):
if not col_def.usable:
return None
elif not col_def.visible:
return Div(data_col=col_def.col_id,
**self.commands.show_columns([col_def], cls="mmt-tooltip dt2-header-hidden"))
else:
return Div(
_mk_header_name(col_def),
Div(cls="dt2-resize-handle"),
style=f"width:{col_def.width}px;",
data_col=col_def.col_id,
data_tooltip=col_def.title,
cls="dt2-cell dt2-resizable flex",
)
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div(
*[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class,
id=f"th_{self._id}"
)
def mk_table_body(self):
df = self._get_filtered_df()
return Div(
*[Div(
*[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row",
data_row=f"{row_index}",
id=f"tr_{self._id}-{row_index}",
) for row_index in df.index],
cls="dt2-body",
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],
id=f"tf_{self._id}",
cls="dt2-row dt2-row-footer",
) for footer in self._state.footers or [DataGridFooterConf()]],
cls="dt2-footer",
id=f"tf_{self._id}"
)
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
if not col_def.usable:
return None
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 "")
return Div(content,
data_col=col_def.col_id,
style=f"width:{col_def.width}px;",
cls=cls_to_use)
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),
cls="dt2-cell-content-checkbox")
def mk_text(value):
return mk_ellipsis(value, cls="dt2-cell-content-text")
def mk_number(value):
return mk_ellipsis(value, cls="dt2-cell-content-number")
def process_cell_content(value):
value_str = str(value)
if FILTER_INPUT_CID not in self._state.filtered or (
keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
return value_str
index = value_str.lower().find(keyword.lower())
if index < 0:
return value_str
len_keyword = len(keyword)
res = [Span(value_str[:index])] if index > 0 else []
res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
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]))
return content, cls
def mk_menu(self, oob=False):
return Div(
# *self.mk_contextual_menu_buttons(),
# self.mk_selection_mode_button(),
# self.mk_resize_table_button(),
# self.mk_reset_filter_button(),
self.mk_download_button(),
self.mk_settings_button(),
cls="flex mr-2",
name="dt-menu",
id=f"cm_{self._id}",
hx_swap_oob='true' if oob else None,
)
def mk_download_button(self):
return mk_icon(icon_open, **self.commands.download())
def mk_settings_button(self):
return mk_icon(icon_settings, **self.commands.open_settings())
def mk_sidebar(self, content, display: bool, oob=False):
return Div(content,
id=f"sb_{self._id}",
hx_swap_oob='true' if oob else None,
cls=f"dt2-sidebar {'active' if display else ''}", ),
def mk_selection_manager(self, new_pos=None, oob=False):
"""
Compute what cell, row of column that must be selected.
:param new_pos: new col_index, row_index
:param oob:
:return:
"""
logger.debug(f"Calling mk_selection_manager with {new_pos} and oob={oob}")
extra_attr = {
"hx-on::after-settle": f"setSelected('{self._id}');setFocus('{self._id}', event);",
} if new_pos else {}
s = self._state
selected = []
# if new_pos is not None:
# append_once(selected, (s.selection_mode, str(self._get_selected_id(s.selection_mode, new_pos))))
#
# # in row or column mode, we want the cell to be highlighted
# if s.selection_mode != DG_SELECTION_MODE_CELL:
# append_once(selected, (DG_SELECTION_MODE_CELL, str(self._get_selected_id(DG_SELECTION_MODE_CELL, new_pos))))
#
# # also highlight the other selected
# for extra_selected in s.extra_selected:
# selection_type, selected_id = extra_selected
# if selection_type == DG_SELECTION_MODE_CELL:
# selection_type += "x" # distinguish regular cell selection
# append_once(selected, (selection_type, str(selected_id)))
select_manager = Div(
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
id=f"tsm_{self._id}",
selection_mode=f"{s.selection.selection_mode}",
hx_swap_oob="outerHTML" if oob else None,
**extra_attr)
return select_manager
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
content_to_use = content if self._state.sidebar_visible else None
return self.mk_sidebar(content_to_use, self._state.sidebar_visible)
def close_sidebar(self, oob=True):
logger.debug(f"close sidebar {self._id}.")
self._state.sidebar_visible = False
return self.mk_sidebar(None, self._state.sidebar_visible, oob=oob)
def toggle_file_upload(self):
return self.toggle_sidebar(self._file_upload)
def toggle_settings(self):
return self.toggle_sidebar(self._columns_settings)
def _get_filtered_df(self):
if self._df is None:
return None
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
df = self._apply_filter(df)
return df
def _apply_sort(self, df):
if df is None:
return None
sorted_columns = []
sorted_asc = []
for sort_def in self._state.sorted:
if sort_def.direction != 0:
sorted_columns.append(sort_def.column_id)
asc = sort_def.direction == 1
sorted_asc.append(asc)
if sorted_columns:
df = df.sort_values(by=sorted_columns, ascending=sorted_asc)
return df
def _apply_filter(self, df):
if df is None:
return None
for col_id, values in self._state.filtered.items():
if col_id == FILTER_INPUT_CID and values is not None:
visible_columns = [c.col_id for c in self._state.columns if c.visible]
df = df[df[visible_columns].map(lambda x: values.lower() in str(x).lower()).any(axis=1)]
else:
df = df[df[col_id].astype(str).isin(values)]
return df
def _create_component(self, component_type: type, component_id: str):
safe_create_component_id = getattr(component_type, "create_component_id")
return InstanceManager.get(self._session,
safe_create_component_id(self._session, component_id, ""),
component_type,
owner=self)
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",
),
Script(f"bindDatagrid('{self._id}', false);"),
id=f"{self._id}",
**self.commands.on_click()
)
@staticmethod
def create_component_id(session):
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
return get_unique_id(prefix)

View File

@@ -0,0 +1,100 @@
from fasthtml.components import *
from assets.icons import icon_dismiss_regular
from components.BaseComponent import BaseComponent
from components.datagrid_new.constants import ROUTE_ROOT, Routes, DATAGRID_INSTANCE_ID
from components_helpers import mk_dialog_buttons
from core.utils import get_unique_id, get_sheets_names
class FileUpload(BaseComponent):
def __init__(self, session, instance_id, owner: None):
super().__init__(session, instance_id)
self.file_name = None
self.file_content = None
self.sheets_names = None
self.selected_sheet_name = None
self._owner = owner
def upload_excel_file(self, file_name, file_content):
self.file_name = file_name
self.file_content = file_content
if file_content is not None:
self.sheets_names = get_sheets_names(file_content)
self.selected_sheet_name = self.sheets_names[0] if len(self.sheets_names) > 0 else 0
return self._mk_select_sheet_name_component()
def on_file_upload(self, file, sheet_name):
# update the selected sheet
self.selected_sheet_name = sheet_name
return self._owner.init_from_excel()
def _mk_file_upload_input(self, oob=False):
return Input(type='file',
name='file',
id=f"fn_{self._id}", # fn stands for 'file name'
value=self.file_name,
hx_preserve=True,
hx_post=f"{ROUTE_ROOT}{Routes.Upload}",
hx_target=f"#sn_{self._id}", # _mk_select_sheet_name_component
hx_swap="outerHTML",
hx_encoding='multipart/form-data',
hx_vals=f'{{"_id": "{self._id}"}}',
hx_swap_oob='true' if oob else None,
cls="file-input file-input-bordered file-input-sm w-full",
)
def _mk_select_sheet_name_component(self, oob=False):
options = [Option("Sheet name...", selected=True, disabled=True)] if self.sheets_names is None else \
[Option(
name,
selected=True if name == self.selected_sheet_name else None,
) for name in self.sheets_names]
return Select(
*options,
name="sheet_name",
id=f"sn_{self._id}", # sn stands for 'sheet name'
cls="select select-bordered select-sm w-full ml-2"
)
def _mk_reset_button(self, oob=False, ):
return Div(icon_dismiss_regular,
cls="icon-24 my-auto icon-btn ml-2",
hx_post=f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
hx_target=f"#{self._id}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self._owner.get_id()}", "key": "Escape"}}',
),
def render(self, oob=False):
return (
Form(
H1("Import Excel file", cls="mb-3 text-xl"),
Div( # Container for file upload and sheet name selection
self._mk_file_upload_input(),
self._mk_select_sheet_name_component(),
cls="flex mb-2 w-full"
),
mk_dialog_buttons(on_cancel=self._owner.commands.cancel()),
cls="flex flex-col justify-center items-center w-full p-5",
hx_post=f"{ROUTE_ROOT}{Routes.UpdateFromExcel}",
hx_target=f"#{self._owner.get_id()}", # table
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self.get_id()}"}}',
)
)
def __ft__(self):
return self.render()
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if prefix is None:
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id(prefix)
return f"{prefix}{suffix}"

View File

@@ -0,0 +1,76 @@
import logging
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.datagrid.icons import icon_filter_regular, icon_dismiss_regular
from components.datagrid_new.constants import Routes, ROUTE_ROOT, FILTER_INPUT_CID, DATAGRID_INSTANCE_ID
from core.utils import get_unique_id
logger = logging.getLogger("FilterAll")
class FilterAll(BaseComponent, ):
"""
This class is search items in the grid
You can enter the items to filter and reset the filtering
"""
def __init__(self, session, _id, owner):
"""
:param datagrid:
"""
super().__init__(session, _id)
self._owner = owner
logger.debug(f"FilterAll component created with id: {self._id}")
def filter(self, filter_value):
# be careful, the order of these two statements is important
return self._owner.filter(FILTER_INPUT_CID, filter_value)
def reset(self):
return self._owner.filter(FILTER_INPUT_CID, None), self._mk_filter_input(True)
def __ft__(self):
return Div(
self._mk_filter_input(False),
self._mk_reset_button(),
cls="flex mb-2 mr-4",
id=f"{self._id}", # fa stands for 'filter all'
)
def _mk_filter_input(self, oob=False):
value = self._owner.get_state().filtered.get(FILTER_INPUT_CID, None)
return Div(
Label(Div(icon_filter_regular, cls="icon-24"),
Input(name='f',
placeholder="Filter...",
value=value,
hx_post=f"{ROUTE_ROOT}{Routes.Filter}",
hx_trigger="keyup changed throttle:300ms",
hx_target=f"#t_{self._owner.get_id()}",
hx_swap="outerHTML",
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'
hx_swap_oob='true' if oob else None,
)
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}"}}'),
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if prefix is None:
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id(prefix)
return f"{prefix}{suffix}"

View File

@@ -0,0 +1,132 @@
import logging
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.datagrid_new.assets.icons import icon_save, icon_save_filled
from components.datagrid_new.constants import DATAGRID_INSTANCE_ID, ROUTE_ROOT, Routes, ADD_NEW_VIEW
from components_helpers import mk_select_option, mk_dialog_buttons, mk_icon
from core.utils import get_unique_id
logger = logging.getLogger("Views")
class Views(BaseComponent):
def __init__(self, session, instance_id, owner):
super().__init__(session, instance_id)
self._owner = owner
self._need_save = False
def add_view(self, view_name):
self._owner.add_view(view_name, self._owner.get_state().columns)
self.recompute_need_save()
return self.render_select_view()
def change_view(self, view_name):
self._owner.change_view(view_name)
self.recompute_need_save()
return self._owner.mk_table(oob=True), self.render_select_view()
def update_view(self, view_name):
self._owner.update_view(view_name, self._owner.get_state().columns)
return self.render_select_view()
def recompute_need_save(self):
# need_save if the current ColumnsState is from the selected view
current_view = self._owner.get_view()
if current_view is None:
return False
saved_columns = current_view.columns
current_columns = self._owner.get_state().columns
self._need_save = not self._same(saved_columns, current_columns)
return self._need_save
def __ft__(self):
return self.render_select_view()
def render_select_view(self, oob=False, ):
return Div(
Select(
mk_select_option("Select View", selected=len(self._owner.get_settings().views) == 0, enabled=False),
*[mk_select_option(view.name, view.name, self._owner.get_state().selected_view)
for view in self._owner.get_settings().views],
mk_select_option(" + Add View", ADD_NEW_VIEW, None),
name="view_name",
cls="select select-sm",
hx_post=f"{ROUTE_ROOT}{Routes.ChangeView}",
hx_target=f"#{self._id}",
hx_swap="outerHTML",
hx_vals=f'{{"_id": "{self._id}"}}',
hx_trigger="change"
),
mk_icon(icon_save_filled, size=24, cls="my-auto ml-2",
**self._owner.commands.update_view(self, self._owner.get_state().selected_view)) if self._need_save else
mk_icon(icon_save, size=24, cls="my-auto ml-2", can_select=False),
cls="flex mb-2 mr-4 dt2-views-container-select",
id=f"{self._id}",
hx_swap_oob='true' if oob else None,
)
def render_create_view(self):
on_ok = {
"hx-post": f"{ROUTE_ROOT}{Routes.AddView}",
"hx-target": f"#{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}
on_cancel = {
"hx-post": f"{ROUTE_ROOT}{Routes.ChangeView}",
"hx-target": f"#{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "view_name": "{self._owner.get_state().selected_view}"}}',
}
return Form(
Input(name="view_name",
type="input",
placeholder="View name...",
cls="input input-sm"),
mk_dialog_buttons(on_ok=on_ok, on_cancel=on_cancel),
cls="flex mb-2 mr-4 dt2-views-container-create",
id=f"{self._id}"
)
@staticmethod
def _same(saved_columns, current_columns):
if id(saved_columns) == id(current_columns):
return True
if saved_columns is None or current_columns is None:
return False
if len(saved_columns) != len(current_columns):
return False
for saved, current in zip(saved_columns, current_columns):
if saved.col_id != current.col_id:
return False
if saved.title != current.title:
return False
if saved.type != current.type:
return False
if saved.visible != current.visible:
return False
if saved.usable != current.usable:
return False
if saved.width != current.width:
return False
return True
@staticmethod
def create_component_id(session, prefix=None, suffix=None):
if prefix is None:
prefix = f"{DATAGRID_INSTANCE_ID}{session['user_id']}"
if suffix is None:
suffix = get_unique_id(prefix)
return f"{prefix}{suffix}"

View File

@@ -0,0 +1,127 @@
import json
from components.datagrid_new.constants import ROUTE_ROOT, Routes
class DataGridCommandManager:
def __init__(self, datagrid):
self.datagrid = datagrid
self._id = self.datagrid.get_id()
def cancel(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnKeyPressed}",
"hx-target": f"#tsm_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}", "key": "Escape"}}',
}
def download(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.Download}",
"hx-target": f"#sb_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
"hx-trigger": "click consume",
"data_tooltip": "Open Excel file",
"class": "mmt-tooltip"
}
def update_view(self, component, view_name):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.UpdateView}",
"hx-target": f"#{component.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{component.get_id()}", "view_name": "{view_name}"}}',
"data_tooltip": "Update view",
}
def add_view(self, view_name, columns):
pass
def open_settings(self):
return {
"hx-get": f"{ROUTE_ROOT}{Routes.Settings}",
"hx-target": f"#sb_{self._id}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
"hx-trigger": "click consume",
"data_tooltip": "Open settings",
"class": "mmt-tooltip"
}
def update_columns_settings(self, component):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
"hx-target": f"#{self.datagrid.get_table_id()}", # table
"hx-swap": "outerHTML",
"hx-vals": f'js:{{"_id": "{self._id}", "updates": getColumnsDefinitions("{component.get_id()}")}}',
}
def hide_columns(self, col_defs: list, cls=""):
return self._get_hide_show_columns_attrs("Hide", col_defs, "false", cls=cls)
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 on_click(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.OnClick}",
"hx-target": f"#tsm_{self._id}",
"hx-trigger"
"hx-swap": "outerHTML",
"hx-vals": f'js:{{_id: "{self._id}", cell_id:getCellId(event), modifier:getClickModifier(event)}}',
}
def _get_hide_show_columns_attrs(self, mode, col_defs: list, new_value, cls=""):
str_col_names = ", ".join(f"'{col_def.title}'" for col_def in col_defs)
tooltip_msg = f"{mode} column{'s' if len(col_defs) > 1 else ''} {str_col_names}"
updates = [{"col_id": col_def.col_id, "visible": new_value} for col_def in col_defs]
return {
"hx_post": f"{ROUTE_ROOT}{Routes.UpdateColumns}",
"hx_vals": f"js:{{'_id': '{self._id}', 'updates':'{json.dumps(updates)}' }}",
"hx_target": f"#t_{self._id}",
"hx_swap": "outerHTML",
"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.
:param items: dictionaries to be merged. If all items are None, None is returned.
:return: A single dictionary containing the merged key-value pairs from all input dictionaries.
:raises NotImplementedError: If a key's value is not a string and exists in multiple input dictionaries.
"""
if all(item is None for item in items):
return None
res = {}
for item in [item for item in items if item is not None]:
for key, value in item.items():
if not key in res:
res[key] = value
else:
if isinstance(res[key], str):
res[key] += " " + value
else:
raise NotImplementedError("")
return res
@staticmethod
def merge_class(cls1, cls2):
return (cls1 + " " + cls2) if cls2 else cls1

View File

@@ -0,0 +1,36 @@
from enum import Enum
DATAGRID_INSTANCE_ID = "__Datagrid__"
ROUTE_ROOT = "/datagrid_new"
FILTER_INPUT_CID = "__filter_input__"
DEFAULT_COLUMN_WIDTH = 100
ADD_NEW_VIEW = "__add_new_view__"
class Routes:
Filter = "/filter" # request the filtering in the grid
ResetFilter = "/reset_filter" #
OnKeyPressed = "/on_key_pressed"
OnClick = "/on_click"
Settings = "/settings"
Upload = "/upload"
Download = "/download"
UpdateFromExcel = "/update_from_excel"
UpdateColumns = "/update_columns"
ChangeView = "/change_view"
AddView = "/add_view"
UpdateView = "/update_view"
class ColumnType(Enum):
RowIndex = "RowIndex"
Text = "Text"
Number = "Number"
Datetime = "DateTime"
Bool = "Boolean"
Choice = "Choice"
List = "List"
class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"

View File

@@ -0,0 +1,128 @@
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"
@dataclasses.dataclass
class DataGridRowState:
row_id: int
visible: bool = True
height: int | None = None
@dataclasses.dataclass
class DataGridColumnState:
col_id: str # name of the column: cannot be changed
col_index: int # index of the column in the dataframe: cannot be changed
title: str = None
type: ColumnType = ColumnType.Text
visible: bool = True
usable: bool = True
width: int = DEFAULT_COLUMN_WIDTH
@dataclasses.dataclass
class DatagridEditionState:
under_edition: tuple[int, int] | None = None
previous_under_edition: tuple[int, int] | None = None
@dataclasses.dataclass
class DatagridSelectionState:
selected: tuple[int, int] | None = None
last_selected: tuple[int, int] | None = None
selection_mode: str = None # valid values are "row", "column" or None for "cell"
extra_selected: list[tuple[str, str | int]] = dataclasses.field(
default_factory=list) # list(tuple(selection_mode, element_id))
last_extra_selected: tuple[int, int] = None
@dataclasses.dataclass
class DataGridFooterConf:
conf: dict[str, str] = dataclasses.field(default_factory=dict)
@dataclasses.dataclass
class DatagridView:
name: str
type: ViewType = ViewType.Table
columns: list[DataGridColumnState] = None
@dataclasses.dataclass
class DataGridSettings:
file_name: str = None
selected_sheet_name: str = None
header_visible: bool = True
views: list[DatagridView] = dataclasses.field(default_factory=list)
@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
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)
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"

View File

@@ -0,0 +1,17 @@
import logging
from fasthtml.fastapp import fast_app
from components.debugger.constants import Routes
from core.instance_manager import InstanceManager
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=}")
instance = InstanceManager.get(session, _id)
return instance.add_tab(digest)

View File

View File

@@ -0,0 +1,79 @@
// Import the svelte-jsoneditor module
import {createJSONEditor} from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor/standalone.js';
/**
* Initializes and displays a JSON editor using the Svelte JSON Editor.
* https://github.com/josdejong/svelte-jsoneditor
* @param {string} debuggerId - The ID of the container where the editor should be rendered.
* @param {string} targetID - The ID of the component that will receive the response (tab manager)
* @param {Object} data - The JSON data to be rendered in the editor.
*/
function showJson(debuggerId, targetID, data) {
const containerId = `dbengine-${debuggerId}`
const container = document.getElementById(containerId);
if (!container) {
console.error(`Container with ID '${containerId}' not found.`);
return;
}
// Clear previous content (if any)
container.innerHTML = '';
// Create and render the editor
const editor = createJSONEditor({
target: container,
props: {
content: {json: data},
mode: 'view', // Options: 'view', 'tree', 'text'
readOnly: true,
onSelect: (selection) => {
// Access the complete JSON
const jsonContent = editor.get()?.json || {};
const {key, value} = getSelectedNodeValue(selection, jsonContent);
htmx.ajax('POST', '/debugger/dbengine', {
target: `#${targetID}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "outerHTML",
values: {
_id: debuggerId,
digest: value,
}
});
},
},
});
console.log('Svelte JSON Editor initialized with data:', data);
}
/**
* Retrieves the selected key and value based on the editor's selection details and JSON structure.
*
* @param {Object} selection - The editor's selection object.
* @param {Object} jsonContent - The JSON content from the editor.
* @returns {{key: string|null, value: any|null}} - The selected key and value.
*/
function getSelectedNodeValue(selection, jsonContent) {
if (!selection || !jsonContent) {
return {key: null, value: null};
}
if (selection.path) {
// If a full path is provided (e.g., ["items", 0, "value"])
const key = selection.path[selection.path.length - 1]; // The last item is the key
const value = selection.path.reduce((current, segment) => {
return current ? current[segment] : undefined;
}, jsonContent);
return {key, value};
}
// For single key/value selections
return {key: selection.key || null, value: jsonContent[selection.key] || null};
}
window.showJson = showJson;

View File

@@ -0,0 +1,9 @@
from fastcore.basics import NotStr
# DatabaseSearch20Regular
icon_dbengine = NotStr("""<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M4 5c0-1.007.875-1.755 1.904-2.223C6.978 2.289 8.427 2 10 2s3.022.289 4.096.777C15.125 3.245 16 3.993 16 5v4.758a4.485 4.485 0 0 0-1-.502V6.698a4.92 4.92 0 0 1-.904.525C13.022 7.711 11.573 8 10 8s-3.022-.289-4.096-.777A4.92 4.92 0 0 1 5 6.698V15c0 .374.356.875 1.318 1.313c.916.416 2.218.687 3.682.687c.22 0 .437-.006.65-.018c.447.367.966.65 1.534.822c-.68.127-1.417.196-2.184.196c-1.573 0-3.022-.289-4.096-.777C4.875 16.755 4 16.007 4 15V5zm1 0c0 .374.356.875 1.318 1.313C7.234 6.729 8.536 7 10 7s2.766-.27 3.682-.687C14.644 5.875 15 5.373 15 5c0-.374-.356-.875-1.318-1.313C12.766 3.271 11.464 3 10 3s-2.766.27-3.682.687C5.356 4.125 5 4.627 5 5zm8.5 12c.786 0 1.512-.26 2.096-.697l2.55 2.55a.5.5 0 1 0 .708-.707l-2.55-2.55A3.5 3.5 0 1 0 13.5 17zm0-1a2.5 2.5 0 1 1 0-5a2.5 2.5 0 0 1 0 5z" fill="currentColor">
</path>
</g>
</svg>""")

View File

@@ -0,0 +1,16 @@
from components.debugger.constants import ROUTE_ROOT, Routes
class Commands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()
def show_dbengine(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngine}",
"hx-target": f"#{self._owner.tabs_manager.get_id()}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"_id": "{self._id}"}}',
}

View File

@@ -0,0 +1,18 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from core.instance_manager import InstanceManager
class DbEngineDebugger(BaseComponent):
def __init__(self, session, _id, owner, data):
super().__init__(session, _id)
self._owner = owner
self.data = data
def __ft__(self):
return Div(id=f"dbengine-{self._id}")
def on_htmx_after_settle(self):
return f"showJson('{self._id}', '{self._owner.tabs_manager.get_id()}', {self.data});"

View File

@@ -0,0 +1,52 @@
import json
import logging
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.constants import DBENGINE_DEBUGGER_INSTANCE_ID
from components_helpers import mk_ellipsis, mk_icon
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)
self.settings_manager = settings_manager
self.db_engine = settings_manager.get_db_engine()
self.tabs_manager = tabs_manager
self.commands = Commands(self)
def add_tab(self, digest):
content = self.mk_db_engine(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()
logger.debug(f"mk_db_engine: {data}")
return DbEngineDebugger(self._session, self._id, self, json.dumps(data))
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"),
cls="flex truncate",
**self.commands.show_dbengine(),
),
id=self._id,
)
@staticmethod
def create_component_id(session):
prefix = f"{DBENGINE_DEBUGGER_INSTANCE_ID}{session['user_id']}"
return get_unique_id(prefix)

View File

@@ -0,0 +1,6 @@
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
ROUTE_ROOT = "/debugger"
class Routes:
DbEngine = "/dbengine" # request the filtering in the grid

View File

@@ -0,0 +1,3 @@
from fasthtml.fastapp import fast_app
drawer_layout_app, rt = fast_app()

View File

@@ -0,0 +1,16 @@
class DrawerLayoutPage:
def __init__(self, name, component, /, _id=None, path: str = None, icon=None):
"""
:param name:
:param component:
:param _id:
:param path:
:param icon:
"""
self.name = name
self.title = name
self.component = component
self.id = _id
self.path = path
self.icon = icon

View File

View File

@@ -0,0 +1,67 @@
main {
display: flex; /* Allows children to use the parent's height */
flex-grow: 1; /* Ensures it grows to fill available space */
height: 100%; /* Inherit height from its parent */
width: 100%;
}
.dl-container {
display: flex; /* Allows children to use the parent's height */
flex-grow: 1; /* Ensures it grows to fill available space */
width: 100%;
}
.dl-main {
flex-grow: 1; /* Ensures it grows to fill available space */
height: 100%; /* Inherit height from its parent */
overflow-x: auto;
}
.dl-main:focus {
outline: none;
}
.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 */
}
.dl-sidebar.collapsed {
overflow: hidden;
width: 0 !important;
padding: 0;
}
.dl-sidebar.collapsed .dl-splitter {
display: none; /* Hides the splitter when sidebar is collapsed */
}
.dl-splitter {
position: absolute;
right: 0;
top: 0;
width: 4px;
height: 100%;
cursor: col-resize;
background-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
}
.dl-splitter::after {
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
content: ''; /* This is required */
position: absolute; /* Position as needed */
z-index: 1;
display: block; /* Makes it a block element */
width: 3px;
background-color: color-mix(in oklab, var(--color-base-content) 50%, #0000);
}
.dl-splitter:hover {
background-color: #aaa; /* Change color on hover */
}

View File

@@ -0,0 +1,50 @@
function bindDrawerLayout(drawerId) {
makeDrawerResizable(drawerId);
}
function makeDrawerResizable(drawerId) {
console.debug("makeResizable on element " + drawerId);
const sidebar = document.getElementById(`sidebar_${drawerId}`);
const splitter = document.getElementById(`splitter_${drawerId}`);
let isResizing = false;
if (!sidebar || !splitter) {
console.error("Invalid sidebar or splitter element.");
return;
}
splitter.addEventListener("mousedown", (e) => {
e.preventDefault();
isResizing = true;
document.body.style.cursor = "col-resize"; // Change cursor style globally
document.body.style.userSelect = "none"; // Disable text selection
});
document.addEventListener("mousemove", (e) => {
if (!isResizing) return;
// Get the new width for the sidebar based on mouse movement
const containerRect = sidebar.parentNode.getBoundingClientRect();
let newWidth = e.clientX - containerRect.left;
// Set minimum and maximum width constraints for the sidebar
if (newWidth < 100) {
newWidth = 100; // Minimum width
} else if (newWidth > 220) {
newWidth = 220; // Maximum width
}
sidebar.style.width = `${newWidth}px`;
});
document.addEventListener("mouseup", () => {
if (isResizing) {
isResizing = false; // Stop resizing
document.body.style.cursor = ""; // Reset cursor
document.body.style.userSelect = ""; // Re-enable text selection
}
});
}

View File

@@ -0,0 +1,19 @@
from fastcore.basics import NotStr
# Fluent - PanelLeftContract20Regular
icon_panel_contract_regular = NotStr(
"""<svg name="panel_contract_regular" class="swap-off h-8 w-8 fill-current" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M10.821 10.5H14.5a.5.5 0 0 0 0-1h-3.679l.999-.874a.5.5 0 1 0-.659-.752l-2 1.75a.5.5 0 0 0 0 .752l2 1.75a.5.5 0 1 0 .659-.752l-.999-.874zM4 4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4zM3 6a1 1 0 0 1 1-1h3v10H4a1 1 0 0 1-1-1V6zm5 9V5h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8z" fill="currentColor">
</path>
</g>
</svg>""")
# Fluent PanelLeftExpand20Regular
icon_panel_expand_regular = NotStr(
"""<svg name="panel_expand_regular" class="swap-on h-8 w-8 fill-current" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
<g fill="none">
<path d="M13.179 10.5l-.998.874a.5.5 0 0 0 .658.752l2-1.75a.5.5 0 0 0 0-.752l-2-1.75a.5.5 0 1 0-.659.752l1 .874H9.5a.5.5 0 0 0 0 1h3.679zM2 14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v8zm2 1a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h3v10H4zm4 0V5h8a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H8z" fill="currentColor">
</path>
</g>
</svg>""")

View File

@@ -0,0 +1,69 @@
from fasthtml.components import *
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.tabs.components.MyTabs import MyTabs
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
class DrawerLayout(BaseComponent):
def __init__(self, session: dict | None,
_id: str = None,
settings_manager: SettingsManager = None):
super().__init__(session, _id)
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)
def __ft__(self):
return Div(
Div(
Div(
self._add_stuff,
self._repositories,
self._debugger,
),
Div(cls="dl-splitter", id=f"splitter_{self._id}"),
id=f"sidebar_{self._id}",
cls="dl-sidebar p-2",
name="sidebar"
),
Div(
Label(
Input(type="checkbox",
onclick=f"document.getElementById('sidebar_{self._id}').classList.toggle('collapsed');"),
icon_panel_contract_regular,
icon_panel_expand_regular,
cls="swap",
),
Div(self._tabs, id=f"page_{self._id}", name="page"),
cls='dl-main',
tabindex="0",
),
cls="dl-container flex"
), Script(f"bindDrawerLayout('{self._id}')")
@staticmethod
def create_component_id(session, suffix: str = ""):
return f"{DRAWER_LAYOUT_INSTANCE_ID}{session['user_id']}{suffix}"

View File

@@ -0,0 +1,2 @@
DRAWER_LAYOUT_INSTANCE_ID = "__DrawerLayout__"
ROUTE_ROOT = "/pages"

View File

View File

@@ -0,0 +1,31 @@
from fasthtml.components import *
from components.BaseComponent import BaseComponent
class DummyComponent(BaseComponent):
def __init__(self, session, instance_id):
super().__init__(session, instance_id)
def __ft__(self):
return Div(
Input(id='my-drawer', type='checkbox', cls='drawer-toggle'),
Div(
Label('Open drawer', fr='my-drawer', cls='btn btn-primary drawer-button'),
cls='drawer-content'
),
Div(
Label(fr='my-drawer', aria_label='close sidebar', cls='drawer-overlay'),
Ul(
Li(
A('Sidebar Item 1')
),
Li(
A('Sidebar Item 2')
),
cls='menu bg-base-200 text-base-content min-h-full w-80 p-4'
),
cls='drawer-side'
),
cls='drawer drawer-end'
)

14
src/components/footer.py Normal file
View File

@@ -0,0 +1,14 @@
from fasthtml.common import *
import config
def footer():
"""Creates a consistent footer."""
return Footer(
Div(
P(f"© 2025 {config.APP_NAME}. Built with FastHTML."),
cls="px-4 py-2"
),
cls="footer sm:footer-horizontal bg-neutral text-neutral-content"
)

View File

@@ -0,0 +1,17 @@
import logging
from fasthtml.fastapp import fast_app
from components.form.constants import Routes
from core.instance_manager import InstanceManager
logger = logging.getLogger("FormApp")
form_app, rt = fast_app()
@rt(Routes.OnUpdate)
def put(session, _id: str, data: dict):
logger.debug(f"Entering {Routes.OnUpdate} with args {_id=}, {data=}")
instance = InstanceManager.get(session, _id)
instance.update_state(data)

View File

View File

@@ -0,0 +1,123 @@
import dataclasses
import logging
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components.form.constants import MY_FORM_INSTANCE_ID, Routes, ROUTE_ROOT
from core.utils import get_unique_id
logger = logging.getLogger("MyForm")
@dataclasses.dataclass
class FormField:
name: str
label: str
type: str
class MyForm(BaseComponent):
def __init__(self, session: dict, _id: str,
title: str = None,
fields: list[FormField] = None,
state: dict = None, # to remember the values of the fields
submit: str = "Submit", # submit button
htmx_params: dict = None, # htmx parameters
extra_values: dict = None, # hx_vals parameters, but using python dict rather than javascript
success: str = None,
error: str = None
):
super().__init__(session, _id)
self.title = title
self.fields = fields
self.state: dict = {} if state is None else state
self.submit = submit
self.htmx_params = htmx_params
self.extra_values = extra_values
self.success = success
self.error = error
self.on_dispose = None
def update_state(self, state):
self.state = state
def set_error(self, error_message):
self.error = error_message
def set_success(self, success_message):
self.success = success_message
def dispose(self):
logger.debug("Calling dispose")
if self.on_dispose:
self.on_dispose()
def __ft__(self):
message_alert = None
if self.error:
message_alert = Div(
P(self.error, cls="text-sm"),
cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
)
elif self.success:
message_alert = Div(
P(self.success, cls="text-sm"),
cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
)
return Div(
H1(self.title, cls="text-xl font-bold text-center mb-6"),
Div(
message_alert if message_alert else "",
Form(
Input(type="hidden", name="form_id", value=self._id),
*[self.mk_field(field) for field in self.fields],
Button(
self.submit,
hx_post=self.htmx_params.get("hx-post", None),
hx_target=self.htmx_params.get("hx-target", None),
hx_swap=self.htmx_params.get("hx-swap", None),
hx_vals=self.htmx_params.get("hx-vals", f"js:{{...{self.extra_values} }}" if self.extra_values else None),
cls="btn w-full font-bold py-2 px-4 rounded button-xs"
),
# action=self.action,
# method="post",
cls="mb-6"
),
id="focusable-div"
),
cls="p-8 max-w-md mx-auto"
)
def mk_field(self, field: FormField):
return Div(
Label(field.label, cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type=field.type,
id=field.name,
name=field.name,
placeholder=field.label,
required=True,
value=self.state.get(field.name, None),
hx_put=f"{ROUTE_ROOT}{Routes.OnUpdate}",
hx_trigger="keyup changed delay:300ms",
hx_vals=f'{{"_id": "{self._id}"}}',
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 input-sm"
),
cls="mb-6"
),
@staticmethod
def create_component_id(session):
prefix = f"{MY_FORM_INSTANCE_ID}{session['user_id']}"
return get_unique_id(prefix)

View File

@@ -0,0 +1,5 @@
MY_FORM_INSTANCE_ID = "__MyForm__"
ROUTE_ROOT = "/forms"
class Routes:
OnUpdate = "/on-update"

View File

View File

@@ -0,0 +1,63 @@
from fasthtml.components import *
import config
from auth.auth_manager import AuthManager
from components.BaseComponent import BaseComponent
from components.header.constants import HEADER_INSTANCE_ID
from components.login.constants import Routes as LoginRoutes, ROUTE_ROOT as LOGIN_ROUTE_ROOT
from components.register.constants import Routes as RegisterRoutes, ROUTE_ROOT as REGISTER_ROUTE_ROOT
from components.themecontroller.components.ThemeContoller import ThemeController
from core.instance_manager import InstanceManager
from core.settings_management import SettingsManager
class MyHeader(BaseComponent):
def __init__(self, session, settings_manager: SettingsManager = None):
super().__init__(session, self.create_component_id(session))
self.settings_manager = settings_manager
self.theme_controller = ThemeController(self._session, self.settings_manager)
InstanceManager.register(self._session, self.theme_controller)
def __ft__(self):
# Get authentication status
is_authenticated = AuthManager.is_authenticated(self._session) if self._session else False
is_admin = AuthManager.is_admin(self._session) if self._session else False
username = self._session.get("username", "") if self._session else ""
auth_links = []
if is_authenticated:
auth_links.append(
Div(
# Username display
Span(f"Hello, {username}", cls="mr-4"),
# Logout link
A("Logout", href=LOGIN_ROUTE_ROOT + LoginRoutes.Logout, cls="btn mr-2"),
cls="flex items-center"
)
)
else:
auth_links.append(
Div(
A("Login", href=LOGIN_ROUTE_ROOT + LoginRoutes.Login, cls="btn btn-primary mr-2"),
A("Register", href=REGISTER_ROUTE_ROOT + RegisterRoutes.Register, cls="btn mr-1"),
cls="flex items-center"
)
)
return Div(
Div(
Div(A(config.APP_NAME, href="/", cls="btn btn-ghost text-xl"), cls="flex items-center ml-2", ),
Div(
*auth_links,
self.theme_controller,
cls="flex"
),
cls="flex justify-between w-full"
),
cls="navbar bg-base-300"
)
@staticmethod
def create_component_id(session):
return HEADER_INSTANCE_ID if session is None or 'user_id' not in session else f"{HEADER_INSTANCE_ID}_{session['user_id']}"

View File

@@ -0,0 +1 @@
HEADER_INSTANCE_ID = "__DrawerLayout__"

View File

@@ -0,0 +1,63 @@
import logging
from fasthtml.fastapp import fast_app
from starlette.responses import RedirectResponse
from auth.auth_manager import AuthManager
from auth.email_auth import EmailAuth
from components.login.constants import Routes, LOGIN_INSTANCE_ID
from components.page_layout_new import page_layout_new
from core.instance_manager import InstanceManager
logger = logging.getLogger("LoggingApp")
login_app, rt = fast_app()
@rt(Routes.Login)
def get(error_message: str = None, success_message: str = None):
"""Handler for the login page route."""
instance = InstanceManager.get(None, LOGIN_INSTANCE_ID)
return page_layout_new(None,
instance.settings_manager,
instance.login_page(error_message, success_message))
@rt(Routes.Logout)
def get(session):
"""Handler for logout."""
# Clear session data
AuthManager.logout_user(session)
# Redirect to login page
return RedirectResponse('/login', status_code=303)
@rt(Routes.LoginByEmail)
def post(session, email: str, password: str):
"""Handler for email login."""
# Authenticate user
success, message, user_data = EmailAuth.authenticate(
email=email,
password=password
)
instance = InstanceManager.get(None, LOGIN_INSTANCE_ID)
if not success:
return page_layout_new(
session,
instance.settings_manager,
instance.login_page(error_message=message),
)
# Log in user by setting session data
AuthManager.login_user(session, user_data)
# Make sure that the settings are created for this user
user_id = user_data["id"]
user_email = user_data["email"]
instance.init_user(user_id, user_email)
# Redirect to home page
return RedirectResponse('/', status_code=303)

View File

View File

@@ -0,0 +1,108 @@
from fasthtml.components import *
from components.login.constants import Routes, LOGIN_INSTANCE_ID, ROUTE_ROOT
from core.settings_management import SettingsManager
class Login:
def __init__(self, settings_manager: SettingsManager, error_message=None, success_message=None):
"""
Create the login page.
Args:
error_message: Optional error message to display
success_message: Optional success message to display
Returns:
Components representing the login page
"""
self._id = LOGIN_INSTANCE_ID
self.settings_manager = settings_manager
self.error_message = error_message
self.success_message = success_message
def login_page(self, success_message=None, error_message=None):
self.success_message = success_message
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
if self.error_message:
message_alert = Div(
P(self.error_message, cls="text-sm"),
cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
)
elif self.success_message:
message_alert = Div(
P(self.success_message, cls="text-sm"),
cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
)
return Div(
# Page title
H1("Sign In", cls="text-3xl font-bold text-center mb-6"),
# Login Form
Div(
# Message alert
message_alert if message_alert else "",
# Email login form
Form(
# Email field
Div(
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="email",
id="email",
name="email",
placeholder="you@example.com",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-4"
),
# Password field
Div(
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="password",
id="password",
name="password",
placeholder="Your password",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-6"
),
# Submit button
Button(
"Sign In",
type="submit",
cls="btn w-full font-bold py-2 px-4 rounded"
),
action=ROUTE_ROOT + Routes.LoginByEmail,
method="post",
cls="mb-6"
),
# Registration link
Div(
P(
"Don't have an account? ",
A("Register here", href="/register", cls="text-blue-600 hover:underline"),
cls="text-sm text-gray-600 text-center"
)
),
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
)
)

View File

@@ -0,0 +1,7 @@
LOGIN_INSTANCE_ID = "__login__"
ROUTE_ROOT = "/authlogin"
class Routes:
Login = "/login"
Logout = "/logout"
LoginByEmail = "/email/login"

View File

@@ -0,0 +1,32 @@
from fasthtml.components import *
import config
from components.footer import footer
from components.header.components.MyHeader import MyHeader
def page_layout_new(session, settings_manager, content):
return Html(
Head(
Meta(charset="UTF-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
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"),
),
Body(
page_layout_lite(session, settings_manager, content),
)
)
def page_layout_lite(session, settings_manager, content):
return (
Title(f"{config.APP_NAME}"),
Div(
MyHeader(session, settings_manager),
Main(content, cls="flex-grow"),
footer(),
cls="flex flex-col min-h-screen"
)
)

View File

@@ -0,0 +1,71 @@
import logging
from fasthtml.fastapp import fast_app
from auth.email_auth import EmailAuth
from components.login.constants import LOGIN_INSTANCE_ID
from components.page_layout_new import page_layout_new
from components.register.constants import Routes, REGISTER_INSTANCE_ID
from core.instance_manager import InstanceManager
logger = logging.getLogger("RegisterApp")
register_app, rt = fast_app()
@rt(Routes.Register)
def get(error_message: str = None):
"""Handler for the registration page route."""
instance = InstanceManager.get(None, REGISTER_INSTANCE_ID)
return page_layout_new(None,
instance.settings_manager,
instance.register_page(error_message))
@rt(Routes.RegisterByEmail)
def post(
session,
username: str,
email: str,
password: str,
confirm_password: str
):
"""Handler for email registration."""
instance = InstanceManager.get(None, REGISTER_INSTANCE_ID)
# Validate registration input
is_valid, error_message = EmailAuth.validate_registration(
username=username,
email=email,
password=password,
confirm_password=confirm_password
)
if not is_valid:
return page_layout_new(
session,
instance.settings_manager,
instance.register_page(error_message),
)
# Create user
success, message, user_id = EmailAuth.register_user(
username=username,
email=email,
password=password
)
if not success:
return page_layout_new(
session,
instance.settings_manager,
instance.register_page(message),
)
# Redirect to login with success message
login_instance = InstanceManager.get(session, LOGIN_INSTANCE_ID)
return page_layout_new(
session,
login_instance.settings_manager,
login_instance.login_page(success_message="Registration successful! Please sign in."),
)

View File

View File

@@ -0,0 +1,120 @@
from fasthtml.components import *
from components.register.constants import REGISTER_INSTANCE_ID, ROUTE_ROOT, Routes
class Register:
def __init__(self, settings_manager, error_message: str = None):
self._id = REGISTER_INSTANCE_ID
self.settings_manager = settings_manager
self.error_message = error_message
def register_page(self, error_message: str):
self.error_message = error_message
return self.__ft__()
def __ft__(self):
"""
Create the registration page.
Args:
error_message: Optional error message to display
Returns:
Components representing the registration page
"""
# Create alert for error message
error_alert = None
if self.error_message:
error_alert = Div(
P(self.error_message, cls="text-sm"),
cls="bg-soft bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
)
return Div(
# Page title
H1("Create an Account", cls="text-3xl font-bold text-center mb-6"),
# Registration Form
Div(
# Error alert
error_alert if error_alert else "",
Form(
# Username field
Div(
Label("Username", For="username", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="text",
id="username",
name="username",
placeholder="Choose a username",
required=True,
minlength=3,
maxlength=30,
pattern="[a-zA-Z0-9_-]+",
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
P("Only letters, numbers, underscores, and hyphens", cls="text-xs text-gray-500 mt-1"),
cls="mb-4"
),
# Email field
Div(
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="email",
id="email",
name="email",
placeholder="you@example.com",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-4"
),
# Password field
Div(
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="password",
id="password",
name="password",
placeholder="Create a password",
required=True,
minlength=8,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
P("At least 8 characters with uppercase, lowercase, and number", cls="text-xs text-gray-500 mt-1"),
cls="mb-4"
),
# Confirm password field
Div(
Label("Confirm Password", For="confirm_password", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="password",
id="confirm_password",
name="confirm_password",
placeholder="Confirm your password",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-6"
),
# Submit button
Button(
"Create Account",
type="submit",
cls="btn w-full font-bold py-2 px-4 rounded"
),
action=ROUTE_ROOT + Routes.RegisterByEmail,
method="post",
cls="mb-6"
),
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
)
)

View File

@@ -0,0 +1,6 @@
REGISTER_INSTANCE_ID = "__register__"
ROUTE_ROOT = "/authregister"
class Routes:
Register = "/register"
RegisterByEmail = "/email/register"

Some files were not shown because too many files have changed in this diff Show More