First Working version. I can add table
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
|
||||
|
||||
7
.idea/MyManagingTools.iml
generated
7
.idea/MyManagingTools.iml
generated
@@ -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
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
24
Makefile
Normal file
24
Makefile
Normal 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 {}
|
||||
@@ -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
|
||||
28
src/assets/DrawerLayout.css
Normal file
28
src/assets/DrawerLayout.css
Normal 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;
|
||||
}
|
||||
98
src/assets/DrawerLayout.js
Normal file
98
src/assets/DrawerLayout.js
Normal 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
0
src/assets/__init__.py
Normal file
62
src/assets/css.py
Normal file
62
src/assets/css.py
Normal 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
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
6
src/assets/fragments.py
Normal 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
12
src/assets/icons.py
Normal 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
25
src/assets/main.css
Normal 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
51
src/assets/main.js
Normal 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
|
||||
}
|
||||
16
src/assets/tailwind.config.js
Normal file
16
src/assets/tailwind.config.js
Normal 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
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
0
src/auth/__init__.py
Normal file
123
src/auth/auth_manager.py
Normal file
123
src/auth/auth_manager.py
Normal 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
93
src/auth/email_auth.py
Normal 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
|
||||
27
src/components/BaseComponent.py
Normal file
27
src/components/BaseComponent.py
Normal 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
|
||||
123
src/components/DrawerLayoutOld.py
Normal file
123
src/components/DrawerLayoutOld.py
Normal 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=}")
|
||||
0
src/components/__init__.py
Normal file
0
src/components/__init__.py
Normal file
39
src/components/addstuff/AddStuffApp.py
Normal file
39
src/components/addstuff/AddStuffApp.py
Normal 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)
|
||||
0
src/components/addstuff/__init__.py
Normal file
0
src/components/addstuff/__init__.py
Normal file
0
src/components/addstuff/assets/__init__.py
Normal file
0
src/components/addstuff/assets/__init__.py
Normal file
3
src/components/addstuff/assets/addstuff.js
Normal file
3
src/components/addstuff/assets/addstuff.js
Normal file
@@ -0,0 +1,3 @@
|
||||
function bindRepositories(repositoryId) {
|
||||
bindTooltipsWithDelegation(repositoryId)
|
||||
}
|
||||
21
src/components/addstuff/assets/icons.py
Normal file
21
src/components/addstuff/assets/icons.py
Normal 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>
|
||||
""")
|
||||
34
src/components/addstuff/components/AddStuffMenu.py
Normal file
34
src/components/addstuff/components/AddStuffMenu.py
Normal 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']}"
|
||||
156
src/components/addstuff/components/Repositories.py
Normal file
156
src/components/addstuff/components/Repositories.py
Normal 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']}"
|
||||
0
src/components/addstuff/components/__init__.py
Normal file
0
src/components/addstuff/components/__init__.py
Normal file
9
src/components/addstuff/constants.py
Normal file
9
src/components/addstuff/constants.py
Normal 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"
|
||||
99
src/components/addstuff/settings.py
Normal file
99
src/components/addstuff/settings.py
Normal 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)
|
||||
308
src/components/datagrid/DataGrid.css
Normal file
308
src/components/datagrid/DataGrid.css
Normal 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;
|
||||
}
|
||||
983
src/components/datagrid/DataGrid.js
Normal file
983
src/components/datagrid/DataGrid.js
Normal 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!');
|
||||
// });
|
||||
|
||||
1952
src/components/datagrid/DataGrid.py
Normal file
1952
src/components/datagrid/DataGrid.py
Normal file
File diff suppressed because it is too large
Load Diff
104
src/components/datagrid/DataGridCommandManager.py
Normal file
104
src/components/datagrid/DataGridCommandManager.py
Normal 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
|
||||
144
src/components/datagrid/Readme.md
Normal file
144
src/components/datagrid/Readme.md
Normal 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()
|
||||
```
|
||||
0
src/components/datagrid/__init__.py
Normal file
0
src/components/datagrid/__init__.py
Normal file
66
src/components/datagrid/constants.py
Normal file
66
src/components/datagrid/constants.py
Normal 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
|
||||
]
|
||||
138
src/components/datagrid/icons.py
Normal file
138
src/components/datagrid/icons.py
Normal 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>""")
|
||||
131
src/components/datagrid_new/DataGridApp.py
Normal file
131
src/components/datagrid_new/DataGridApp.py
Normal 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)
|
||||
19
src/components/datagrid_new/Readme.md
Normal file
19
src/components/datagrid_new/Readme.md
Normal 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}` |
|
||||
|
||||
0
src/components/datagrid_new/__init__.py
Normal file
0
src/components/datagrid_new/__init__.py
Normal file
325
src/components/datagrid_new/assets/Datagrid.css
Normal file
325
src/components/datagrid_new/assets/Datagrid.css
Normal 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 *!*/
|
||||
/*}*/
|
||||
395
src/components/datagrid_new/assets/Datagrid.js
Normal file
395
src/components/datagrid_new/assets/Datagrid.js
Normal 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;
|
||||
}
|
||||
0
src/components/datagrid_new/assets/__init__.py
Normal file
0
src/components/datagrid_new/assets/__init__.py
Normal file
56
src/components/datagrid_new/assets/icons.py
Normal file
56
src/components/datagrid_new/assets/icons.py
Normal 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>""")
|
||||
102
src/components/datagrid_new/components/ColumnsSettings.py
Normal file
102
src/components/datagrid_new/components/ColumnsSettings.py
Normal 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}"
|
||||
636
src/components/datagrid_new/components/DataGrid.py
Normal file
636
src/components/datagrid_new/components/DataGrid.py
Normal 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)
|
||||
100
src/components/datagrid_new/components/FileUpload.py
Normal file
100
src/components/datagrid_new/components/FileUpload.py
Normal 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}"
|
||||
76
src/components/datagrid_new/components/FilterAll.py
Normal file
76
src/components/datagrid_new/components/FilterAll.py
Normal 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}"
|
||||
132
src/components/datagrid_new/components/Views.py
Normal file
132
src/components/datagrid_new/components/Views.py
Normal 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}"
|
||||
0
src/components/datagrid_new/components/__init__.py
Normal file
0
src/components/datagrid_new/components/__init__.py
Normal file
127
src/components/datagrid_new/components/commands.py
Normal file
127
src/components/datagrid_new/components/commands.py
Normal 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
|
||||
36
src/components/datagrid_new/constants.py
Normal file
36
src/components/datagrid_new/constants.py
Normal 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"
|
||||
128
src/components/datagrid_new/settings.py
Normal file
128
src/components/datagrid_new/settings.py
Normal 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"
|
||||
|
||||
17
src/components/debugger/DebuggerApp.py
Normal file
17
src/components/debugger/DebuggerApp.py
Normal 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)
|
||||
0
src/components/debugger/__init__.py
Normal file
0
src/components/debugger/__init__.py
Normal file
79
src/components/debugger/assets/Debugger.js
Normal file
79
src/components/debugger/assets/Debugger.js
Normal 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;
|
||||
|
||||
0
src/components/debugger/assets/__init__.py
Normal file
0
src/components/debugger/assets/__init__.py
Normal file
9
src/components/debugger/assets/icons.py
Normal file
9
src/components/debugger/assets/icons.py
Normal 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>""")
|
||||
16
src/components/debugger/commands.py
Normal file
16
src/components/debugger/commands.py
Normal 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}"}}',
|
||||
}
|
||||
18
src/components/debugger/components/DbEngineDebugger.py
Normal file
18
src/components/debugger/components/DbEngineDebugger.py
Normal 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});"
|
||||
|
||||
52
src/components/debugger/components/Debugger.py
Normal file
52
src/components/debugger/components/Debugger.py
Normal 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)
|
||||
0
src/components/debugger/components/__init__.py
Normal file
0
src/components/debugger/components/__init__.py
Normal file
6
src/components/debugger/constants.py
Normal file
6
src/components/debugger/constants.py
Normal file
@@ -0,0 +1,6 @@
|
||||
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
|
||||
ROUTE_ROOT = "/debugger"
|
||||
|
||||
class Routes:
|
||||
DbEngine = "/dbengine" # request the filtering in the grid
|
||||
|
||||
3
src/components/drawerlayout/DrawerLayoutApp.py
Normal file
3
src/components/drawerlayout/DrawerLayoutApp.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
drawer_layout_app, rt = fast_app()
|
||||
16
src/components/drawerlayout/DrawerLayoutPage.py
Normal file
16
src/components/drawerlayout/DrawerLayoutPage.py
Normal 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
|
||||
0
src/components/drawerlayout/__init__.py
Normal file
0
src/components/drawerlayout/__init__.py
Normal file
67
src/components/drawerlayout/assets/DrawerLayout.css
Normal file
67
src/components/drawerlayout/assets/DrawerLayout.css
Normal 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 */
|
||||
}
|
||||
50
src/components/drawerlayout/assets/DrawerLayout.js
Normal file
50
src/components/drawerlayout/assets/DrawerLayout.js
Normal 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
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
0
src/components/drawerlayout/assets/__init__.py
Normal file
0
src/components/drawerlayout/assets/__init__.py
Normal file
19
src/components/drawerlayout/assets/icons.py
Normal file
19
src/components/drawerlayout/assets/icons.py
Normal 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>""")
|
||||
69
src/components/drawerlayout/components/DrawerLayout.py
Normal file
69
src/components/drawerlayout/components/DrawerLayout.py
Normal 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}"
|
||||
0
src/components/drawerlayout/components/__init__.py
Normal file
0
src/components/drawerlayout/components/__init__.py
Normal file
2
src/components/drawerlayout/constants.py
Normal file
2
src/components/drawerlayout/constants.py
Normal file
@@ -0,0 +1,2 @@
|
||||
DRAWER_LAYOUT_INSTANCE_ID = "__DrawerLayout__"
|
||||
ROUTE_ROOT = "/pages"
|
||||
0
src/components/dummy/__init__.py
Normal file
0
src/components/dummy/__init__.py
Normal file
31
src/components/dummy/components/DummyComponent.py
Normal file
31
src/components/dummy/components/DummyComponent.py
Normal 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'
|
||||
)
|
||||
0
src/components/dummy/components/__init__.py
Normal file
0
src/components/dummy/components/__init__.py
Normal file
14
src/components/footer.py
Normal file
14
src/components/footer.py
Normal 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"
|
||||
)
|
||||
17
src/components/form/FormApp.py
Normal file
17
src/components/form/FormApp.py
Normal 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)
|
||||
0
src/components/form/__init__.py
Normal file
0
src/components/form/__init__.py
Normal file
123
src/components/form/components/MyForm.py
Normal file
123
src/components/form/components/MyForm.py
Normal 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)
|
||||
0
src/components/form/components/__init__.py
Normal file
0
src/components/form/components/__init__.py
Normal file
5
src/components/form/constants.py
Normal file
5
src/components/form/constants.py
Normal file
@@ -0,0 +1,5 @@
|
||||
MY_FORM_INSTANCE_ID = "__MyForm__"
|
||||
ROUTE_ROOT = "/forms"
|
||||
|
||||
class Routes:
|
||||
OnUpdate = "/on-update"
|
||||
0
src/components/header/__init__.py
Normal file
0
src/components/header/__init__.py
Normal file
63
src/components/header/components/MyHeader.py
Normal file
63
src/components/header/components/MyHeader.py
Normal 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']}"
|
||||
0
src/components/header/components/__init__.py
Normal file
0
src/components/header/components/__init__.py
Normal file
1
src/components/header/constants.py
Normal file
1
src/components/header/constants.py
Normal file
@@ -0,0 +1 @@
|
||||
HEADER_INSTANCE_ID = "__DrawerLayout__"
|
||||
63
src/components/login/LoginApp.py
Normal file
63
src/components/login/LoginApp.py
Normal 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)
|
||||
0
src/components/login/__init__.py
Normal file
0
src/components/login/__init__.py
Normal file
108
src/components/login/components/Login.py
Normal file
108
src/components/login/components/Login.py
Normal 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"
|
||||
)
|
||||
)
|
||||
0
src/components/login/components/__init__.py
Normal file
0
src/components/login/components/__init__.py
Normal file
7
src/components/login/constants.py
Normal file
7
src/components/login/constants.py
Normal file
@@ -0,0 +1,7 @@
|
||||
LOGIN_INSTANCE_ID = "__login__"
|
||||
ROUTE_ROOT = "/authlogin"
|
||||
|
||||
class Routes:
|
||||
Login = "/login"
|
||||
Logout = "/logout"
|
||||
LoginByEmail = "/email/login"
|
||||
32
src/components/page_layout_new.py
Normal file
32
src/components/page_layout_new.py
Normal 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"
|
||||
)
|
||||
)
|
||||
71
src/components/register/RegisterApp.py
Normal file
71
src/components/register/RegisterApp.py
Normal 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."),
|
||||
)
|
||||
0
src/components/register/__init__.py
Normal file
0
src/components/register/__init__.py
Normal file
120
src/components/register/components/Register.py
Normal file
120
src/components/register/components/Register.py
Normal 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"
|
||||
)
|
||||
)
|
||||
0
src/components/register/components/__init__.py
Normal file
0
src/components/register/components/__init__.py
Normal file
6
src/components/register/constants.py
Normal file
6
src/components/register/constants.py
Normal 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
Reference in New Issue
Block a user