Working and layout's drawers resize
This commit is contained in:
10
src/app.py
10
src/app.py
@@ -30,6 +30,7 @@ def index(session):
|
|||||||
layout.set_footer("Goodbye World")
|
layout.set_footer("Goodbye World")
|
||||||
for i in range(1000):
|
for i in range(1000):
|
||||||
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||||
|
layout.right_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||||
|
|
||||||
tabs_manager = TabsManager(session, _id="main")
|
tabs_manager = TabsManager(session, _id="main")
|
||||||
btn = mk.button("Add Tab",
|
btn = mk.button("Add Tab",
|
||||||
@@ -37,8 +38,15 @@ def index(session):
|
|||||||
"Add a new tab",
|
"Add a new tab",
|
||||||
tabs_manager.on_new_tab, "Tabs", Div("Content")).
|
tabs_manager.on_new_tab, "Tabs", Div("Content")).
|
||||||
htmx(target=f"#{tabs_manager.get_id()}"))
|
htmx(target=f"#{tabs_manager.get_id()}"))
|
||||||
|
|
||||||
|
btn_show_right_drawer = mk.button("show",
|
||||||
|
command=Command("ShowRightDrawer",
|
||||||
|
"Show Right Drawer",
|
||||||
|
layout.toggle_drawer, "right"),
|
||||||
|
id="btn_show_right_drawer_id")
|
||||||
|
|
||||||
|
layout.set_footer(btn_show_right_drawer)
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
layout.set_footer(btn)
|
|
||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,116 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout Drawer Resizer Styles
|
||||||
|
*
|
||||||
|
* Styles for the resizable drawer borders with visual feedback
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Layout Drawer Resizer Styles
|
||||||
|
*
|
||||||
|
* Styles for the resizable drawer borders with visual feedback
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Ensure drawer has relative positioning and no overflow */
|
||||||
|
.mf-layout-drawer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content wrapper handles scrolling */
|
||||||
|
.mf-layout-drawer-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base resizer styles */
|
||||||
|
.mf-layout-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resizer on the right side (for left drawer) */
|
||||||
|
.mf-layout-resizer-right {
|
||||||
|
right: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resizer on the left side (for right drawer) */
|
||||||
|
.mf-layout-resizer-left {
|
||||||
|
left: 0;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover state */
|
||||||
|
.mf-layout-resizer:hover {
|
||||||
|
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active state during resize */
|
||||||
|
.mf-layout-drawer-resizing .mf-layout-resizer {
|
||||||
|
background-color: rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions during resize for smooth dragging */
|
||||||
|
.mf-layout-drawer-resizing {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection during resize */
|
||||||
|
.mf-layout-resizing {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor override for entire body during resize */
|
||||||
|
.mf-layout-resizing * {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visual indicator for resizer on hover - subtle border */
|
||||||
|
.mf-layout-resizer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 2px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-layout-resizer-right::before {
|
||||||
|
right: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-layout-resizer-left::before {
|
||||||
|
left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-layout-resizer:hover::before,
|
||||||
|
.mf-layout-drawer-resizing .mf-layout-resizer::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* *********************************************** */
|
||||||
|
/* *********** Tabs Manager Component ************ */
|
||||||
|
/* *********************************************** */
|
||||||
|
|
||||||
/* Tabs Manager Container */
|
/* Tabs Manager Container */
|
||||||
.mf-tabs-manager {
|
.mf-tabs-manager {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,119 +1,157 @@
|
|||||||
/**
|
/**
|
||||||
* MF Layout Component - JavaScript Controller
|
* Layout Drawer Resizer
|
||||||
* Manages drawer state and provides programmatic control
|
*
|
||||||
|
* Handles resizing of left and right drawers with drag functionality.
|
||||||
|
* Communicates with server via HTMX to persist width changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global registry for layout instances
|
|
||||||
if (typeof window.mfLayoutInstances === 'undefined') {
|
|
||||||
window.mfLayoutInstances = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize a layout instance with drawer controls
|
* Initialize drawer resizer functionality for a specific layout instance
|
||||||
* @param {string} layoutId - The unique ID of the layout (mf-layout-xxx)
|
*
|
||||||
|
* @param {string} layoutId - The ID of the layout instance to initialize
|
||||||
*/
|
*/
|
||||||
function initLayout(layoutId) {
|
function initLayoutResizer(layoutId) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MIN_WIDTH = 150;
|
||||||
|
const MAX_WIDTH = 600;
|
||||||
|
|
||||||
|
let isResizing = false;
|
||||||
|
let currentResizer = null;
|
||||||
|
let currentDrawer = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startWidth = 0;
|
||||||
|
let side = null;
|
||||||
|
|
||||||
const layoutElement = document.getElementById(layoutId);
|
const layoutElement = document.getElementById(layoutId);
|
||||||
|
|
||||||
if (!layoutElement) {
|
if (!layoutElement) {
|
||||||
console.error(`Layout with id "${layoutId}" not found`);
|
console.error(`Layout element with ID "${layoutId}" not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create layout controller object
|
/**
|
||||||
const layoutController = {
|
* Initialize resizer functionality for this layout instance
|
||||||
layoutId: layoutId,
|
*/
|
||||||
element: layoutElement,
|
function initResizers() {
|
||||||
|
const resizers = layoutElement.querySelectorAll('.mf-layout-resizer');
|
||||||
|
|
||||||
/**
|
resizers.forEach(resizer => {
|
||||||
* Get drawer element by side
|
// Remove existing listener if any to avoid duplicates
|
||||||
* @param {string} side - 'left' or 'right'
|
resizer.removeEventListener('mousedown', handleMouseDown);
|
||||||
* @returns {HTMLElement|null} The drawer element
|
resizer.addEventListener('mousedown', handleMouseDown);
|
||||||
*/
|
});
|
||||||
getDrawer: function (side) {
|
}
|
||||||
if (side !== 'left' && side !== 'right') {
|
|
||||||
console.error(`Invalid drawer side: "${side}". Must be "left" or "right".`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawerClass = side === 'left' ? '.mf-layout-left-drawer' : '.mf-layout-right-drawer';
|
/**
|
||||||
return this.element.querySelector(drawerClass);
|
* Handle mouse down event on resizer
|
||||||
},
|
*/
|
||||||
|
function handleMouseDown(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
/**
|
currentResizer = e.target;
|
||||||
* Check if a drawer is currently open
|
side = currentResizer.dataset.side;
|
||||||
* @param {string} side - 'left' or 'right'
|
currentDrawer = currentResizer.closest('.mf-layout-drawer');
|
||||||
* @returns {boolean} True if drawer is open
|
|
||||||
*/
|
|
||||||
isDrawerOpen: function (side) {
|
|
||||||
const drawer = this.getDrawer(side);
|
|
||||||
return drawer ? !drawer.classList.contains('collapsed') : false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
if (!currentDrawer) {
|
||||||
* Open a drawer
|
console.error('Could not find drawer element');
|
||||||
* @param {string} side - 'left' or 'right'
|
return;
|
||||||
*/
|
|
||||||
openDrawer: function (side) {
|
|
||||||
const drawer = this.getDrawer(side);
|
|
||||||
if (drawer) {
|
|
||||||
drawer.classList.remove('collapsed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a drawer
|
|
||||||
* @param {string} side - 'left' or 'right'
|
|
||||||
*/
|
|
||||||
closeDrawer: function (side) {
|
|
||||||
const drawer = this.getDrawer(side);
|
|
||||||
if (drawer) {
|
|
||||||
drawer.classList.add('collapsed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle a drawer between open and closed
|
|
||||||
* @param {string} side - 'left' or 'right'
|
|
||||||
*/
|
|
||||||
toggleDrawer: function (side) {
|
|
||||||
if (this.isDrawerOpen(side)) {
|
|
||||||
this.closeDrawer(side);
|
|
||||||
} else {
|
|
||||||
this.openDrawer(side);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize event listeners for toggle buttons
|
|
||||||
*/
|
|
||||||
initEventListeners: function () {
|
|
||||||
// Get all toggle buttons within this layout
|
|
||||||
const toggleButtons = this.element.querySelectorAll('[class*="mf-layout-toggle"]');
|
|
||||||
|
|
||||||
toggleButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const side = button.getAttribute('data-side');
|
|
||||||
if (side) {
|
|
||||||
this.toggleDrawer(side);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize event listeners
|
isResizing = true;
|
||||||
layoutController.initEventListeners();
|
startX = e.clientX;
|
||||||
|
startWidth = currentDrawer.offsetWidth;
|
||||||
|
|
||||||
// Store instance in global registry for programmatic access
|
// Add event listeners for mouse move and up
|
||||||
window.mfLayoutInstances[layoutId] = layoutController;
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
// Log successful initialization
|
// Add resizing class for visual feedback
|
||||||
console.log(`Layout "${layoutId}" initialized successfully`);
|
document.body.classList.add('mf-layout-resizing');
|
||||||
}
|
currentDrawer.classList.add('mf-layout-drawer-resizing');
|
||||||
|
}
|
||||||
// Export for module environments if needed
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
/**
|
||||||
module.exports = {initLayout};
|
* Handle mouse move event during resize
|
||||||
|
*/
|
||||||
|
function handleMouseMove(e) {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let newWidth;
|
||||||
|
|
||||||
|
if (side === 'left') {
|
||||||
|
// Left drawer: increase width when moving right
|
||||||
|
newWidth = startWidth + (e.clientX - startX);
|
||||||
|
} else if (side === 'right') {
|
||||||
|
// Right drawer: increase width when moving left
|
||||||
|
newWidth = startWidth - (e.clientX - startX);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain width between min and max
|
||||||
|
newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth));
|
||||||
|
|
||||||
|
// Update drawer width visually
|
||||||
|
currentDrawer.style.width = `${newWidth}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse up event - end resize and save to server
|
||||||
|
*/
|
||||||
|
function handleMouseUp(e) {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
isResizing = false;
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// Remove resizing classes
|
||||||
|
document.body.classList.remove('mf-layout-resizing');
|
||||||
|
currentDrawer.classList.remove('mf-layout-drawer-resizing');
|
||||||
|
|
||||||
|
// Get final width
|
||||||
|
const finalWidth = currentDrawer.offsetWidth;
|
||||||
|
const commandId = currentResizer.dataset.commandId;
|
||||||
|
|
||||||
|
if (!commandId) {
|
||||||
|
console.error('No command ID found on resizer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send width update to server
|
||||||
|
saveDrawerWidth(commandId, finalWidth);
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
currentResizer = null;
|
||||||
|
currentDrawer = null;
|
||||||
|
side = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save drawer width to server via HTMX
|
||||||
|
*/
|
||||||
|
function saveDrawerWidth(commandId, width) {
|
||||||
|
htmx.ajax('POST', '/myfasthtml/commands', {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
},
|
||||||
|
swap: "outerHTML",
|
||||||
|
target: `#${currentDrawer.id}`,
|
||||||
|
values: {
|
||||||
|
c_id: commandId,
|
||||||
|
width: width
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize resizers
|
||||||
|
initResizers();
|
||||||
|
|
||||||
|
// Re-initialize after HTMX swaps within this layout
|
||||||
|
layoutElement.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
initResizers();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -26,12 +26,31 @@ class LayoutState(DbObject):
|
|||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner.get_session(), owner.get_id())
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
self.right_drawer_open: bool = False
|
self.right_drawer_open: bool = True
|
||||||
|
self.left_drawer_width: int = 250
|
||||||
|
self.right_drawer_width: int = 250
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
def toggle_left_drawer(self):
|
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||||
return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, "left")
|
return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, side)
|
||||||
|
|
||||||
|
def update_drawer_width(self, side: Literal["left", "right"]):
|
||||||
|
"""
|
||||||
|
Create a command to update drawer width.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
side: Which drawer to update ("left" or "right")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Command: Command object for updating drawer width
|
||||||
|
"""
|
||||||
|
return Command(
|
||||||
|
f"UpdateDrawerWidth_{side}",
|
||||||
|
f"Update {side} drawer width",
|
||||||
|
self._owner.update_drawer_width,
|
||||||
|
side
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Layout(SingleInstance):
|
class Layout(SingleInstance):
|
||||||
@@ -103,7 +122,32 @@ class Layout(SingleInstance):
|
|||||||
return self._mk_left_drawer_icon(), self._mk_left_drawer()
|
return self._mk_left_drawer_icon(), self._mk_left_drawer()
|
||||||
elif side == "right":
|
elif side == "right":
|
||||||
self._state.right_drawer_open = not self._state.right_drawer_open
|
self._state.right_drawer_open = not self._state.right_drawer_open
|
||||||
return self._mk_left_drawer_icon(), self._mk_right_drawer()
|
return self._mk_right_drawer_icon(), self._mk_right_drawer()
|
||||||
|
else:
|
||||||
|
raise ValueError("Invalid drawer side")
|
||||||
|
|
||||||
|
def update_drawer_width(self, side: Literal["left", "right"], width: int):
|
||||||
|
"""
|
||||||
|
Update the width of a drawer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
side: Which drawer to update ("left" or "right")
|
||||||
|
width: New width in pixels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div: Updated drawer component
|
||||||
|
"""
|
||||||
|
# Constrain width between min and max values
|
||||||
|
width = max(150, min(600, width))
|
||||||
|
|
||||||
|
logger.debug(f"Update drawer width: {side=}, {width=}")
|
||||||
|
|
||||||
|
if side == "left":
|
||||||
|
self._state.left_drawer_width = width
|
||||||
|
return self._mk_left_drawer()
|
||||||
|
elif side == "right":
|
||||||
|
self._state.right_drawer_width = width
|
||||||
|
return self._mk_right_drawer()
|
||||||
else:
|
else:
|
||||||
raise ValueError("Invalid drawer side")
|
raise ValueError("Invalid drawer side")
|
||||||
|
|
||||||
@@ -151,12 +195,26 @@ class Layout(SingleInstance):
|
|||||||
Render the left drawer if enabled.
|
Render the left drawer if enabled.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div or None: FastHTML Div component for left drawer, or None if disabled
|
Div: FastHTML Div component for left drawer
|
||||||
"""
|
"""
|
||||||
return Div(
|
resizer = Div(
|
||||||
|
cls="mf-layout-resizer mf-layout-resizer-right",
|
||||||
|
data_command_id=self.commands.update_drawer_width("left").id,
|
||||||
|
data_side="left"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap content in scrollable container
|
||||||
|
content_wrapper = Div(
|
||||||
*self.left_drawer.get_content(),
|
*self.left_drawer.get_content(),
|
||||||
|
cls="mf-layout-drawer-content"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
content_wrapper,
|
||||||
|
resizer,
|
||||||
id=f"{self._id}_ld",
|
id=f"{self._id}_ld",
|
||||||
cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}",
|
cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}",
|
||||||
|
style=f"width: {self._state.left_drawer_width if self._state.left_drawer_open else 0}px;"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_right_drawer(self):
|
def _mk_right_drawer(self):
|
||||||
@@ -164,18 +222,37 @@ class Layout(SingleInstance):
|
|||||||
Render the right drawer if enabled.
|
Render the right drawer if enabled.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div or None: FastHTML Div component for right drawer, or None if disabled
|
Div: FastHTML Div component for right drawer
|
||||||
"""
|
"""
|
||||||
return Div(
|
resizer = Div(
|
||||||
|
cls="mf-layout-resizer mf-layout-resizer-left",
|
||||||
|
data_command_id=self.commands.update_drawer_width("right").id,
|
||||||
|
data_side="right"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wrap content in scrollable container
|
||||||
|
content_wrapper = Div(
|
||||||
*self.right_drawer.get_content(),
|
*self.right_drawer.get_content(),
|
||||||
|
cls="mf-layout-drawer-content"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
resizer,
|
||||||
|
content_wrapper,
|
||||||
cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}",
|
cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}",
|
||||||
id=f"{self._id}_rd",
|
id=f"{self._id}_rd",
|
||||||
|
style=f"width: {self._state.right_drawer_width if self._state.right_drawer_open else 0}px;"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_left_drawer_icon(self):
|
def _mk_left_drawer_icon(self):
|
||||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||||
id=f"{self._id}_ldi",
|
id=f"{self._id}_ldi",
|
||||||
command=self.commands.toggle_left_drawer())
|
command=self.commands.toggle_drawer("left"))
|
||||||
|
|
||||||
|
def _mk_right_drawer_icon(self):
|
||||||
|
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||||
|
id=f"{self._id}_rdi",
|
||||||
|
command=self.commands.toggle_drawer("right"))
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""
|
"""
|
||||||
@@ -192,6 +269,7 @@ class Layout(SingleInstance):
|
|||||||
self._mk_main(),
|
self._mk_main(),
|
||||||
self._mk_right_drawer(),
|
self._mk_right_drawer(),
|
||||||
self._mk_footer(),
|
self._mk_footer(),
|
||||||
|
Script(f"initLayoutResizer('{self._id}');"),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
cls="mf-layout",
|
cls="mf-layout",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,9 +112,9 @@ class Command(BaseCommand):
|
|||||||
def __init__(self, name, description, callback, *args, **kwargs):
|
def __init__(self, name, description, callback, *args, **kwargs):
|
||||||
super().__init__(name, description)
|
super().__init__(name, description)
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
|
self.callback_parameters = dict(inspect.signature(callback).parameters)
|
||||||
self.args = args
|
self.args = args
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
self.requires_client_response = 'client_response' in inspect.signature(callback).parameters
|
|
||||||
|
|
||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
ret_from_bindings = []
|
ret_from_bindings = []
|
||||||
@@ -125,10 +125,15 @@ class Command(BaseCommand):
|
|||||||
for data in self._bindings:
|
for data in self._bindings:
|
||||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||||
|
|
||||||
if self.requires_client_response:
|
new_kwargs = self.kwargs.copy()
|
||||||
ret = self.callback(client_response=client_response, *self.args, **self.kwargs)
|
if client_response:
|
||||||
else:
|
for k, v in client_response.items():
|
||||||
ret = self.callback(*self.args, **self.kwargs)
|
if k in self.callback_parameters:
|
||||||
|
new_kwargs[k] = v
|
||||||
|
if 'client_response' in self.callback_parameters:
|
||||||
|
new_kwargs['client_response'] = client_response
|
||||||
|
|
||||||
|
ret = self.callback(*self.args, **new_kwargs)
|
||||||
|
|
||||||
for data in self._bindings:
|
for data in self._bindings:
|
||||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||||
|
|||||||
@@ -223,6 +223,118 @@ def debug_session(session):
|
|||||||
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
||||||
|
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def build_args_kwargs(
|
||||||
|
parameters,
|
||||||
|
values,
|
||||||
|
default_args: Optional[list] = None,
|
||||||
|
default_kwargs: Optional[dict] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Build (args, kwargs) from a sequence or dict of inspect.Parameter and a dict of values.
|
||||||
|
|
||||||
|
- POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD fill `args`
|
||||||
|
- KEYWORD_ONLY fill `kwargs`
|
||||||
|
- VAR_POSITIONAL (*args) accepts list/tuple values
|
||||||
|
- VAR_KEYWORD (**kwargs) accepts dict values
|
||||||
|
- If not found, fallback to default_args / default_kwargs when provided
|
||||||
|
- Raises ValueError for missing required or unknown parameters
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
parameters = {p.name: p for p in parameters}
|
||||||
|
|
||||||
|
ordered_params = list(parameters.values())
|
||||||
|
|
||||||
|
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in ordered_params)
|
||||||
|
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ordered_params)
|
||||||
|
|
||||||
|
args = []
|
||||||
|
kwargs = {}
|
||||||
|
consumed_names = set()
|
||||||
|
|
||||||
|
default_args = default_args or []
|
||||||
|
default_kwargs = default_kwargs or {}
|
||||||
|
|
||||||
|
# 1 Handle positional parameters
|
||||||
|
positional_params = [
|
||||||
|
p for p in ordered_params
|
||||||
|
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, p in enumerate(positional_params):
|
||||||
|
if p.name in values:
|
||||||
|
args.append(values[p.name])
|
||||||
|
consumed_names.add(p.name)
|
||||||
|
elif i < len(default_args):
|
||||||
|
args.append(default_args[i])
|
||||||
|
elif p.default is not inspect.Parameter.empty:
|
||||||
|
args.append(p.default)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Missing required positional argument: {p.name}")
|
||||||
|
|
||||||
|
# 2 Handle *args
|
||||||
|
for p in ordered_params:
|
||||||
|
if p.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||||
|
if p.name in values:
|
||||||
|
val = values[p.name]
|
||||||
|
if not isinstance(val, (list, tuple)):
|
||||||
|
raise ValueError(f"*{p.name} must be a list or tuple, got {type(val).__name__}")
|
||||||
|
args.extend(val)
|
||||||
|
consumed_names.add(p.name)
|
||||||
|
elif len(default_args) > len(positional_params):
|
||||||
|
# Add any remaining default_args beyond fixed positionals
|
||||||
|
args.extend(default_args[len(positional_params):])
|
||||||
|
|
||||||
|
# 3 Handle keyword-only parameters
|
||||||
|
for p in ordered_params:
|
||||||
|
if p.kind == inspect.Parameter.KEYWORD_ONLY:
|
||||||
|
if p.name in values:
|
||||||
|
kwargs[p.name] = values[p.name]
|
||||||
|
consumed_names.add(p.name)
|
||||||
|
elif p.name in default_kwargs:
|
||||||
|
kwargs[p.name] = default_kwargs[p.name]
|
||||||
|
elif p.default is not inspect.Parameter.empty:
|
||||||
|
kwargs[p.name] = p.default
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Missing required keyword-only argument: {p.name}")
|
||||||
|
|
||||||
|
# 4 Handle **kwargs
|
||||||
|
for p in ordered_params:
|
||||||
|
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
||||||
|
if p.name in values:
|
||||||
|
val = values[p.name]
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
raise ValueError(f"**{p.name} must be a dict, got {type(val).__name__}")
|
||||||
|
kwargs.update(val)
|
||||||
|
consumed_names.add(p.name)
|
||||||
|
# Merge any unmatched names if **kwargs exists
|
||||||
|
remaining = {
|
||||||
|
k: v for k, v in values.items()
|
||||||
|
if k not in consumed_names and k not in parameters
|
||||||
|
}
|
||||||
|
# Also merge default_kwargs not used yet
|
||||||
|
for k, v in default_kwargs.items():
|
||||||
|
if k not in kwargs:
|
||||||
|
kwargs[k] = v
|
||||||
|
kwargs.update(remaining)
|
||||||
|
break
|
||||||
|
|
||||||
|
# 5 Handle unknown / unexpected parameters (if no **kwargs)
|
||||||
|
if not has_var_keyword:
|
||||||
|
unexpected = [k for k in values if k not in consumed_names and k in parameters]
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(f"Unexpected parameters: {unexpected}")
|
||||||
|
extra = [k for k in values if k not in consumed_names and k not in parameters]
|
||||||
|
if extra:
|
||||||
|
raise ValueError(f"Unknown parameters: {extra}")
|
||||||
|
|
||||||
|
return args, kwargs
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
def post(session, c_id: str, client_response: dict = None):
|
def post(session, c_id: str, client_response: dict = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -24,101 +24,136 @@ def reset_command_manager():
|
|||||||
CommandsManager.reset()
|
CommandsManager.reset()
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_create_a_command_with_no_params():
|
class TestCommandDefault:
|
||||||
command = Command('test', 'Command description', callback)
|
|
||||||
assert command.id is not None
|
def test_i_can_create_a_command_with_no_params(self):
|
||||||
assert command.name == 'test'
|
command = Command('test', 'Command description', callback)
|
||||||
assert command.description == 'Command description'
|
assert command.id is not None
|
||||||
assert command.execute() == "Hello World"
|
assert command.name == 'test'
|
||||||
|
assert command.description == 'Command description'
|
||||||
|
assert command.execute() == "Hello World"
|
||||||
|
|
||||||
|
def test_command_are_registered(self):
|
||||||
|
command = Command('test', 'Command description', callback)
|
||||||
|
assert CommandsManager.commands.get(str(command.id)) is command
|
||||||
|
|
||||||
|
|
||||||
def test_command_are_registered():
|
class TestCommandBind:
|
||||||
command = Command('test', 'Command description', callback)
|
|
||||||
assert CommandsManager.commands.get(str(command.id)) is command
|
def test_i_can_bind_a_command_to_an_element(self):
|
||||||
|
command = Command('test', 'Command description', callback)
|
||||||
|
elt = Button()
|
||||||
|
updated = command.bind_ft(elt)
|
||||||
|
|
||||||
|
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
|
||||||
|
|
||||||
|
assert matches(updated, expected)
|
||||||
|
|
||||||
|
def test_i_can_suppress_swapping_with_target_attr(self):
|
||||||
|
command = Command('test', 'Command description', callback).htmx(target=None)
|
||||||
|
elt = Button()
|
||||||
|
updated = command.bind_ft(elt)
|
||||||
|
|
||||||
|
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
|
||||||
|
|
||||||
|
assert matches(updated, expected)
|
||||||
|
|
||||||
|
def test_i_can_bind_a_command_to_an_observable(self):
|
||||||
|
data = Data("hello")
|
||||||
|
|
||||||
|
def on_data_change(old, new):
|
||||||
|
return old, new
|
||||||
|
|
||||||
|
def another_callback():
|
||||||
|
data.value = "new value"
|
||||||
|
return "another callback result"
|
||||||
|
|
||||||
|
make_observable(data)
|
||||||
|
bind(data, "value", on_data_change)
|
||||||
|
command = Command('test', 'Command description', another_callback).bind(data)
|
||||||
|
|
||||||
|
res = command.execute()
|
||||||
|
|
||||||
|
assert res == ["another callback result", ("hello", "new value")]
|
||||||
|
|
||||||
|
def test_i_can_bind_a_command_to_an_observable_2(self):
|
||||||
|
data = Data("hello")
|
||||||
|
|
||||||
|
def on_data_change(old, new):
|
||||||
|
return old, new
|
||||||
|
|
||||||
|
def another_callback():
|
||||||
|
data.value = "new value"
|
||||||
|
return ["another 1", "another 2"]
|
||||||
|
|
||||||
|
make_observable(data)
|
||||||
|
bind(data, "value", on_data_change)
|
||||||
|
command = Command('test', 'Command description', another_callback).bind(data)
|
||||||
|
|
||||||
|
res = command.execute()
|
||||||
|
|
||||||
|
assert res == ["another 1", "another 2", ("hello", "new value")]
|
||||||
|
|
||||||
|
def test_by_default_swap_is_set_to_outer_html(self):
|
||||||
|
command = Command('test', 'Command description', callback)
|
||||||
|
elt = Button()
|
||||||
|
updated = command.bind_ft(elt)
|
||||||
|
|
||||||
|
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML")
|
||||||
|
|
||||||
|
assert matches(updated, expected)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("return_values", [
|
||||||
|
[Div(), Div(), "hello", Div()], # list
|
||||||
|
(Div(), Div(), "hello", Div()) # tuple
|
||||||
|
])
|
||||||
|
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
|
||||||
|
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||||
|
|
||||||
|
def another_callback():
|
||||||
|
return return_values
|
||||||
|
|
||||||
|
command = Command('test', 'Command description', another_callback)
|
||||||
|
|
||||||
|
res = command.execute()
|
||||||
|
|
||||||
|
assert "hx_swap_oob" not in res[0].attrs
|
||||||
|
assert res[1].attrs["hx-swap-oob"] == "true"
|
||||||
|
assert res[3].attrs["hx-swap-oob"] == "true"
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_bind_a_command_to_an_element():
|
class TestCommandExecute:
|
||||||
command = Command('test', 'Command description', callback)
|
|
||||||
elt = Button()
|
|
||||||
updated = command.bind_ft(elt)
|
|
||||||
|
|
||||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
|
def test_i_can_create_a_command_with_no_params(self):
|
||||||
|
command = Command('test', 'Command description', callback)
|
||||||
|
assert command.id is not None
|
||||||
|
assert command.name == 'test'
|
||||||
|
assert command.description == 'Command description'
|
||||||
|
assert command.execute() == "Hello World"
|
||||||
|
|
||||||
assert matches(updated, expected)
|
def test_i_can_execute_a_command_with_closed_parameter(self):
|
||||||
|
"""The parameter is given when the command is created."""
|
||||||
|
|
||||||
|
def callback_with_param(param):
|
||||||
|
return f"Hello {param}"
|
||||||
|
|
||||||
def test_i_can_suppress_swapping_with_target_attr():
|
command = Command('test', 'Command description', callback_with_param, "world")
|
||||||
command = Command('test', 'Command description', callback).htmx(target=None)
|
assert command.execute() == "Hello world"
|
||||||
elt = Button()
|
|
||||||
updated = command.bind_ft(elt)
|
|
||||||
|
|
||||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
|
def test_i_can_execute_a_command_with_open_parameter(self):
|
||||||
|
"""The parameter is given by the browser, when the command is executed."""
|
||||||
|
|
||||||
assert matches(updated, expected)
|
def callback_with_param(name):
|
||||||
|
return f"Hello {name}"
|
||||||
|
|
||||||
|
command = Command('test', 'Command description', callback_with_param)
|
||||||
|
assert command.execute(client_response={"name": "world"}) == "Hello world"
|
||||||
|
|
||||||
def test_i_can_bind_a_command_to_an_observable():
|
def test_i_can_convert_arg_in_execute(self):
|
||||||
data = Data("hello")
|
"""The parameter is given by the browser, when the command is executed."""
|
||||||
|
|
||||||
def on_data_change(old, new):
|
def callback_with_param(number: int):
|
||||||
return old, new
|
assert isinstance(number, int)
|
||||||
|
|
||||||
def another_callback():
|
command = Command('test', 'Command description', callback_with_param)
|
||||||
data.value = "new value"
|
command.execute(client_response={"number": "10"})
|
||||||
return "another callback result"
|
|
||||||
|
|
||||||
make_observable(data)
|
|
||||||
bind(data, "value", on_data_change)
|
|
||||||
command = Command('test', 'Command description', another_callback).bind(data)
|
|
||||||
|
|
||||||
res = command.execute()
|
|
||||||
|
|
||||||
assert res == ["another callback result", ("hello", "new value")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_bind_a_command_to_an_observable_2():
|
|
||||||
data = Data("hello")
|
|
||||||
|
|
||||||
def on_data_change(old, new):
|
|
||||||
return old, new
|
|
||||||
|
|
||||||
def another_callback():
|
|
||||||
data.value = "new value"
|
|
||||||
return ["another 1", "another 2"]
|
|
||||||
|
|
||||||
make_observable(data)
|
|
||||||
bind(data, "value", on_data_change)
|
|
||||||
command = Command('test', 'Command description', another_callback).bind(data)
|
|
||||||
|
|
||||||
res = command.execute()
|
|
||||||
|
|
||||||
assert res == ["another 1", "another 2", ("hello", "new value")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_by_default_swap_is_set_to_outer_html():
|
|
||||||
command = Command('test', 'Command description', callback)
|
|
||||||
elt = Button()
|
|
||||||
updated = command.bind_ft(elt)
|
|
||||||
|
|
||||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML")
|
|
||||||
|
|
||||||
assert matches(updated, expected)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("return_values", [
|
|
||||||
[Div(), Div(), "hello", Div()], # list
|
|
||||||
(Div(), Div(), "hello", Div()) # tuple
|
|
||||||
])
|
|
||||||
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(return_values):
|
|
||||||
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
|
||||||
|
|
||||||
def another_callback():
|
|
||||||
return return_values
|
|
||||||
|
|
||||||
command = Command('test', 'Command description', another_callback)
|
|
||||||
|
|
||||||
res = command.execute()
|
|
||||||
|
|
||||||
assert "hx_swap_oob" not in res[0].attrs
|
|
||||||
assert res[1].attrs["hx-swap-oob"] == "true"
|
|
||||||
assert res[3].attrs["hx-swap-oob"] == "true"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user