Adding FileUpload control and updated related components

This commit is contained in:
2025-11-18 21:54:49 +01:00
parent 3de9aff15c
commit 4199427c71
11 changed files with 179 additions and 11 deletions

View File

@@ -4,12 +4,14 @@ import yaml
from fasthtml import serve from fasthtml import serve
from myfasthtml.controls.CommandsDebugger import CommandsDebugger from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Layout import Layout from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.instances import InstancesManager, RootInstance from myfasthtml.core.instances import InstancesManager, RootInstance
from myfasthtml.icons.carbon import volume_object_storage from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p3 import folder_open20_regular
from myfasthtml.myfastapp import create_app from myfasthtml.myfastapp import create_app
with open('logging.yaml', 'r') as f: with open('logging.yaml', 'r') as f:
@@ -49,10 +51,16 @@ def index(session):
command=tabs_manager.commands.add_tab("Commands", commands_debugger), command=tabs_manager.commands.add_tab("Commands", commands_debugger),
id=commands_debugger.get_id()) id=commands_debugger.get_id())
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout)),
id="file_upload_id")
layout.header_left.add(tabs_manager.add_tab_btn()) layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer) layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger) layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger) layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.set_main(tabs_manager) layout.set_main(tabs_manager)
return layout return layout

View File

@@ -1,8 +1,20 @@
:root { :root {
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000); --color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--font-weight-medium: 500;
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
} }
.mf-icon-16 { .mf-icon-16 {
width: 16px; width: 16px;
min-width: 16px; min-width: 16px;
@@ -219,6 +231,7 @@
bottom: 0; bottom: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 1rem;
} }
/* Base resizer styles */ /* Base resizer styles */
@@ -299,6 +312,16 @@
opacity: 1; opacity: 1;
} }
.mf-layout-group {
font-weight: bold;
/*font-size: var(--text-sm);*/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}
/* *********************************************** */ /* *********************************************** */
/* *********** Tabs Manager Component ************ */ /* *********** Tabs Manager Component ************ */
/* *********************************************** */ /* *********************************************** */

View File

@@ -0,0 +1,49 @@
from dataclasses import dataclass
from fasthtml.components import *
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.bindings import Binding, LambdaConverter
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
class FileUploadState(DbObject):
def __init__(self, owner):
super().__init__(owner.get_session(), owner.get_id())
with self.initializing():
# persisted in DB
# must not be persisted in DB (prefix ns_ = no_saving_)
self.ns_file_name: str = ""
class FileUpload(MultipleInstance):
@dataclass
class BindingData:
filename: str = ""
def __init__(self, parent, _id=None):
super().__init__(Ids.FileUpload, parent, _id=_id)
self._binding = self.BindingData()
self._state = FileUploadState(self)
def render(self):
return Div(
mk.mk(Input(type='file',
name='file',
id=f"fn_{self._id}", # fn stands for 'file name'
value=self._state.ns_file_name,
hx_preserve="true",
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
binding=Binding(self._binding, "filename")
),
mk.mk(Label(), binding=Binding(self._binding, "filename",
LambdaConverter(lambda x: x.filename if hasattr(x, "filename") else x))),
cls="flex"
)
def __ft__(self):
return self.render()

View File

@@ -69,20 +69,36 @@ class Layout(SingleInstance):
class Content: class Content:
def __init__(self, owner): def __init__(self, owner):
self._owner = owner self._owner = owner
self._content = [] self._content = {}
self._groups = []
self._ids = set() self._ids = set()
def add(self, content): def add_group(self, group, group_ft=None):
group_ft = group_ft or Div(group, cls="mf-layout-group")
if not group:
group_ft = None
self._groups.append((group, group_ft))
self._content[group] = []
def add(self, content, group=None):
content_id = get_id(content) content_id = get_id(content)
if content_id in self._ids: if content_id in self._ids:
return return
self._content.append(content)
if group not in self._content:
self.add_group(group)
self._content[group] = []
self._content[group].append(content)
if content_id is not None: if content_id is not None:
self._ids.add(content_id) self._ids.add(content_id)
def get_content(self): def get_content(self):
return self._content return self._content
def get_groups(self):
return self._groups
def __init__(self, session, app_name, parent=None): def __init__(self, session, app_name, parent=None):
""" """
@@ -224,7 +240,14 @@ class Layout(SingleInstance):
# Wrap content in scrollable container # Wrap content in scrollable container
content_wrapper = Div( content_wrapper = Div(
*self.left_drawer.get_content(), *[
(
Div(cls="divider") if index > 0 else None,
group_ft,
*[item for item in self.left_drawer.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(self.left_drawer.get_groups())
],
cls="mf-layout-drawer-content" cls="mf-layout-drawer-content"
) )

View File

@@ -11,6 +11,7 @@ class Ids:
Boundaries = "mf-boundaries" Boundaries = "mf-boundaries"
CommandsDebugger = "mf-commands-debugger" CommandsDebugger = "mf-commands-debugger"
DbManager = "mf-dbmanager" DbManager = "mf-dbmanager"
FileUpload = "mf-file-upload"
InstancesDebugger = "mf-instances-debugger" InstancesDebugger = "mf-instances-debugger"
Layout = "mf-layout" Layout = "mf-layout"
Root = "mf-root" Root = "mf-root"

View File

@@ -129,6 +129,14 @@ class DataConverter:
pass pass
class LambdaConverter(DataConverter):
def __init__(self, func):
self.func = func
def convert(self, data):
return self.func(data)
class BooleanConverter(DataConverter): class BooleanConverter(DataConverter):
def convert(self, data): def convert(self, data):
if data is None: if data is None:

View File

@@ -55,7 +55,7 @@ class DbObject:
self._initializing = old_state self._initializing = old_state
def __setattr__(self, name: str, value: str): def __setattr__(self, name: str, value: str):
if name.startswith("_") or getattr(self, "_initializing", False): if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
super().__setattr__(name, value) super().__setattr__(name, value)
return return
@@ -74,7 +74,8 @@ class DbObject:
self._save_self() self._save_self()
def _save_self(self): def _save_self(self):
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")} props = {k: getattr(self, k) for k, v in self._get_properties().items() if
not k.startswith("_") and not k.startswith("ns")}
if props: if props:
self._db_manager.save(self._name, props) self._db_manager.save(self._name, props)

View File

@@ -38,6 +38,7 @@ class BaseInstance:
def get_parent(self): def get_parent(self):
return self._parent return self._parent
class SingleInstance(BaseInstance): class SingleInstance(BaseInstance):
""" """
Base class for instances that can only have one instance at a time. Base class for instances that can only have one instance at a time.
@@ -107,7 +108,11 @@ class InstancesManager:
if instance_type: if instance_type:
if not issubclass(instance_type, SingleInstance): if not issubclass(instance_type, SingleInstance):
assert parent is not None, "Parent instance must be provided if not SingleInstance" assert parent is not None, "Parent instance must be provided if not SingleInstance"
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
if isinstance(parent, MultipleInstance):
return instance_type(parent, _id=instance_id, *args, **kwargs)
else:
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
else: else:
raise raise

View File

@@ -1,6 +1,7 @@
import logging import logging
from myfasthtml.controls.CommandsDebugger import CommandsDebugger from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
@@ -12,6 +13,7 @@ logger = logging.getLogger("InstancesHelper")
class InstancesHelper: class InstancesHelper:
@staticmethod @staticmethod
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str): def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
logger.debug(f"Dynamic get: {component_type} {instance_id}")
if component_type == Ids.VisNetwork: if component_type == Ids.VisNetwork:
return InstancesManager.get(parent.get_session(), instance_id, return InstancesManager.get(parent.get_session(), instance_id,
VisNetwork, parent=parent, _id=instance_id) VisNetwork, parent=parent, _id=instance_id)
@@ -21,6 +23,7 @@ class InstancesHelper:
elif component_type == Ids.CommandsDebugger: elif component_type == Ids.CommandsDebugger:
return InstancesManager.get(parent.get_session(), instance_id, return InstancesManager.get(parent.get_session(), instance_id,
CommandsDebugger, parent.get_session(), parent, instance_id) CommandsDebugger, parent.get_session(), parent, instance_id)
elif component_type == Ids.FileUpload:
return InstancesManager.get(parent.get_session(), instance_id, FileUpload, parent)
logger.warning(f"Unknown component type: {component_type}") logger.warning(f"Unknown component type: {component_type}")
return None return None

View File

@@ -266,6 +266,7 @@ def post(session, b_id: str, values: dict):
from myfasthtml.core.bindings import BindingsManager from myfasthtml.core.bindings import BindingsManager
binding = BindingsManager.get_binding(b_id) binding = BindingsManager.get_binding(b_id)
if binding: if binding:
return binding.update(values) res = binding.update(values)
return res
raise ValueError(f"Binding with ID '{b_id}' not found.") raise ValueError(f"Binding with ID '{b_id}' not found.")

View File

@@ -106,6 +106,52 @@ def test_i_can_init_from_db_with_dataclass(session, db_manager):
assert dummy.number == 34 assert dummy.number == 34
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
class DummyObject(DbObject):
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
with self.initializing():
self.to_save: str = "value"
self._not_to_save: str = "value"
self.ns_not_to_save: str = "value"
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(session)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(session, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, sess: dict):
super().__init__(sess, "DummyObject", db_manager)
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(session)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_db_is_updated_when_attribute_is_modified(session, db_manager): def test_db_is_updated_when_attribute_is_modified(session, db_manager):
@dataclass @dataclass
class DummyObject(DbObject): class DummyObject(DbObject):