diff --git a/src/app.py b/src/app.py index 04aebd5..888524b 100644 --- a/src/app.py +++ b/src/app.py @@ -4,12 +4,14 @@ import yaml from fasthtml import serve from myfasthtml.controls.CommandsDebugger import CommandsDebugger +from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.Layout import Layout from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.helpers import Ids, mk from myfasthtml.core.instances import InstancesManager, RootInstance from myfasthtml.icons.carbon import volume_object_storage +from myfasthtml.icons.fluent_p3 import folder_open20_regular from myfasthtml.myfastapp import create_app with open('logging.yaml', 'r') as f: @@ -49,10 +51,16 @@ def index(session): command=tabs_manager.commands.add_tab("Commands", commands_debugger), 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_right.add(btn_show_right_drawer) - layout.left_drawer.add(btn_show_instances_debugger) - layout.left_drawer.add(btn_show_commands_debugger) + layout.left_drawer.add(btn_show_instances_debugger, "Debugger") + layout.left_drawer.add(btn_show_commands_debugger, "Debugger") + layout.left_drawer.add(btn_file_upload, "Test") layout.set_main(tabs_manager) return layout diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index eca1506..9302f80 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -1,8 +1,20 @@ :root { --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 { width: 16px; min-width: 16px; @@ -219,6 +231,7 @@ bottom: 0; overflow-y: auto; overflow-x: hidden; + padding: 1rem; } /* Base resizer styles */ @@ -299,6 +312,16 @@ 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 ************ */ /* *********************************************** */ diff --git a/src/myfasthtml/controls/FileUpload.py b/src/myfasthtml/controls/FileUpload.py new file mode 100644 index 0000000..9a0106d --- /dev/null +++ b/src/myfasthtml/controls/FileUpload.py @@ -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() diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 209407b..553a0ed 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -69,20 +69,36 @@ class Layout(SingleInstance): class Content: def __init__(self, owner): self._owner = owner - self._content = [] + self._content = {} + self._groups = [] 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) if content_id in self._ids: 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: self._ids.add(content_id) def get_content(self): return self._content + + def get_groups(self): + return self._groups def __init__(self, session, app_name, parent=None): """ @@ -224,7 +240,14 @@ class Layout(SingleInstance): # Wrap content in scrollable container 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" ) diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index bfd7c4b..88d79bf 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -11,6 +11,7 @@ class Ids: Boundaries = "mf-boundaries" CommandsDebugger = "mf-commands-debugger" DbManager = "mf-dbmanager" + FileUpload = "mf-file-upload" InstancesDebugger = "mf-instances-debugger" Layout = "mf-layout" Root = "mf-root" diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index 6ef71cf..aee50f6 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -129,6 +129,14 @@ class DataConverter: pass +class LambdaConverter(DataConverter): + def __init__(self, func): + self.func = func + + def convert(self, data): + return self.func(data) + + class BooleanConverter(DataConverter): def convert(self, data): if data is None: diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 1a922d0..a057a47 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -55,7 +55,7 @@ class DbObject: self._initializing = old_state 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) return @@ -74,7 +74,8 @@ class DbObject: self._save_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: self._db_manager.save(self._name, props) diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index e12cf24..466a289 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -38,6 +38,7 @@ class BaseInstance: def get_parent(self): return self._parent + class SingleInstance(BaseInstance): """ Base class for instances that can only have one instance at a time. @@ -107,7 +108,11 @@ class InstancesManager: if instance_type: if not issubclass(instance_type, 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: raise diff --git a/src/myfasthtml/core/instances_helper.py b/src/myfasthtml/core/instances_helper.py index d7d34a3..e1a15d3 100644 --- a/src/myfasthtml/core/instances_helper.py +++ b/src/myfasthtml/core/instances_helper.py @@ -1,6 +1,7 @@ import logging from myfasthtml.controls.CommandsDebugger import CommandsDebugger +from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.controls.helpers import Ids @@ -12,6 +13,7 @@ logger = logging.getLogger("InstancesHelper") class InstancesHelper: @staticmethod 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: return InstancesManager.get(parent.get_session(), instance_id, VisNetwork, parent=parent, _id=instance_id) @@ -21,6 +23,7 @@ class InstancesHelper: elif component_type == Ids.CommandsDebugger: return InstancesManager.get(parent.get_session(), 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}") return None diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index f4a66a4..47a3326 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -266,6 +266,7 @@ def post(session, b_id: str, values: dict): from myfasthtml.core.bindings import BindingsManager binding = BindingsManager.get_binding(b_id) if binding: - return binding.update(values) + res = binding.update(values) + return res raise ValueError(f"Binding with ID '{b_id}' not found.") diff --git a/tests/core/test_db_object.py b/tests/core/test_db_object.py index 065995a..1411991 100644 --- a/tests/core/test_db_object.py +++ b/tests/core/test_db_object.py @@ -106,6 +106,52 @@ def test_i_can_init_from_db_with_dataclass(session, db_manager): 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): @dataclass class DummyObject(DbObject):