From c09aa2ff719cf6935d36094dab383834f42aa59c Mon Sep 17 00:00:00 2001 From: Ken Kinder Date: Sat, 7 Sep 2024 10:43:43 +0100 Subject: [PATCH] Add CssClass for runtime CSS definitions (#60) Also refactor tests into separate integration and unittest dirs, for easiness --- examples/tutorial/06_components/components.py | 31 +++++++++- examples/tutorial/06_components/index.html | 31 +++------- puepy/__init__.py | 2 +- puepy/core.py | 52 +++++++++++++++- puepy/util.py | 14 ++++- tests/unittests/__init__.py | 0 tests/{ => unittests}/dom_test.py | 0 tests/{ => unittests}/dom_tools.py | 0 tests/{ => unittests}/test_application.py | 0 tests/{ => unittests}/test_core.py | 60 +++++++++++++++++++ .../test_default_id_generator.py | 0 tests/{ => unittests}/test_reactivity.py | 0 tests/{ => unittests}/test_router.py | 0 tests/{ => unittests}/test_storage.py | 0 tests/{ => unittests}/test_utils.py | 29 +++++---- 15 files changed, 179 insertions(+), 40 deletions(-) create mode 100644 tests/unittests/__init__.py rename tests/{ => unittests}/dom_test.py (100%) rename tests/{ => unittests}/dom_tools.py (100%) rename tests/{ => unittests}/test_application.py (100%) rename tests/{ => unittests}/test_core.py (72%) rename tests/{ => unittests}/test_default_id_generator.py (100%) rename tests/{ => unittests}/test_reactivity.py (100%) rename tests/{ => unittests}/test_router.py (100%) rename tests/{ => unittests}/test_storage.py (100%) rename tests/{ => unittests}/test_utils.py (93%) diff --git a/examples/tutorial/06_components/components.py b/examples/tutorial/06_components/components.py index 4959183..6cb4910 100644 --- a/examples/tutorial/06_components/components.py +++ b/examples/tutorial/06_components/components.py @@ -1,16 +1,40 @@ -from puepy import Application, Page, Component, t +from puepy import Application, Page, Component, t, CssClass app = Application() +# +# These are Python-defined css classes. You can use them instead of strings when specifying CSS classes: +# Arguments can be either strings or keyword arguments +warning = CssClass(color="darkorange") +error = CssClass("color: red") +success = CssClass("color: darkgreen") + # Using the @t.component decorator registers the component for use elsewhere @t.component() class Card(Component): props = ["type", "button_text"] - default_classes = ["card"] + + # + # For CSS specific to a component, put it here. The generated class name is unique per CssClass instance, + # so this can work to "scope" CSS. + card = CssClass( + margin="1em", + padding="1em", + background_color="#efefef", + border="solid 2px #333", + ) + + default_classes = [card] + + type_styles = { + "success": success, + "warning": warning, + "error": error, + } def populate(self): - with t.h2(classes=[self.type]): + with t.h2(classes=[self.type_styles[self.type]]): self.insert_slot("card-header") with t.p(): self.insert_slot() # If you don't pass a name, it defaults to the main slot @@ -27,6 +51,7 @@ def initial(self): def populate(self): t.h1("Components are useful") + t.message() with t.card(type="success", button_text="Okay Then", on_my_custom_event=self.handle_custom_event) as card: with card.slot("card-header"): diff --git a/examples/tutorial/06_components/index.html b/examples/tutorial/06_components/index.html index f7d9329..8053ea2 100644 --- a/examples/tutorial/06_components/index.html +++ b/examples/tutorial/06_components/index.html @@ -1,31 +1,14 @@ - PuePy Components - - - - - + PuePy Components + + + + -
Loading...
- +
Loading...
+ diff --git a/puepy/__init__.py b/puepy/__init__.py index 36aa8a9..338e235 100644 --- a/puepy/__init__.py +++ b/puepy/__init__.py @@ -1,3 +1,3 @@ -from puepy.core import Component, Page, Prop, t +from puepy.core import Component, Page, Prop, CssClass, t from puepy.application import Application from .version import __version__ diff --git a/puepy/core.py b/puepy/core.py index bf4e7e6..371ee5a 100644 --- a/puepy/core.py +++ b/puepy/core.py @@ -20,6 +20,23 @@ ) +class CssClass: + def __init__(self, *rules, **kw_rules): + self.rules = list(rules) + + for k, v in kw_rules.items(): + k = k.replace("_", "-") + self.rules.append(f"{k}: {v}") + + self.class_name = f"-ps-{id(self)}" + + def __str__(self): + return self.class_name + + def render_css(self): + return f".{self.class_name} {{ {';'.join(self.rules)} }}" + + class Prop: """ Class representing a prop for a component. @@ -348,12 +365,14 @@ def render_unknown_child(self, element, child): raise Exception(f"Unknown child type {type(child)} onto {self}") def get_render_classes(self, attrs): - return merge_classes( + class_names, python_css_classes = merge_classes( set(self.get_default_classes()), attrs.pop("class_name", []), attrs.pop("classes", []), attrs.pop("class", []), ) + self.page.python_css_classes.update(python_css_classes) + return class_names def get_default_classes(self): """ @@ -417,6 +436,13 @@ def mount(self, selector_or_element): element.innerHTML = "" element.appendChild(self.render()) self.recursive_call("on_ready") + self.add_python_css_classes() + + def add_python_css_classes(self): + """ + This is only done at the page level. + """ + pass def recursive_call(self, method, *args, **kwargs): """ @@ -752,6 +778,8 @@ def __init__(self, matched_route=None, application=None, **kwargs): self.matched_route = matched_route self._application = application + self.python_css_classes = set() + self._redraw_timeout_set = False self.redraw_list = set() @@ -799,6 +827,28 @@ def _do_redraw(self): except Exception as e: self.application.handle_error(e) + def add_python_css_classes(self): + """ + Iterates over Python css classes defined by elements and puts them in the head of the document. + """ + css_class: CssClass + css_rules = [] + for css_class in self.python_css_classes: + css_rules.append(css_class.render_css()) + + if css_rules: + el = self.document.getElementById("puepy-runtime-css") + if el: + created = False + else: + el = self.document.createElement("style") + el.type = "text/css" + created = True + el.innerHTML = "" + el.appendChild(self.document.createTextNode("\n".join(css_rules))) + if created: + self.document.head.appendChild(el) + class Builder: def __init__(self): diff --git a/puepy/util.py b/puepy/util.py index 680df29..edbdff7 100644 --- a/puepy/util.py +++ b/puepy/util.py @@ -18,7 +18,10 @@ def mixed_to_underscores(input_string, separator="_"): def merge_classes(*items): + from .core import CssClass + classes = set() + python_css_classes = [] exclude_classes = set() for class_option in items: @@ -31,14 +34,23 @@ def merge_classes(*items): exclude_classes.update([key for key, value in class_option.items() if not value]) elif class_option is None: pass + elif isinstance(class_option, CssClass): + classes.add(class_option) else: classes.update(list(class_option)) + + for c in list(classes): + if isinstance(c, CssClass): + classes.remove(c) + classes.add(c.class_name) + python_css_classes.append(c) + for c in classes: if c.startswith("/"): exclude_classes.add(c) exclude_classes.add(c[1:]) classes.difference_update(exclude_classes) - return classes + return classes, python_css_classes def jsobj(**kwargs): diff --git a/tests/unittests/__init__.py b/tests/unittests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dom_test.py b/tests/unittests/dom_test.py similarity index 100% rename from tests/dom_test.py rename to tests/unittests/dom_test.py diff --git a/tests/dom_tools.py b/tests/unittests/dom_tools.py similarity index 100% rename from tests/dom_tools.py rename to tests/unittests/dom_tools.py diff --git a/tests/test_application.py b/tests/unittests/test_application.py similarity index 100% rename from tests/test_application.py rename to tests/unittests/test_application.py diff --git a/tests/test_core.py b/tests/unittests/test_core.py similarity index 72% rename from tests/test_core.py rename to tests/unittests/test_core.py index d9a5dca..d919291 100644 --- a/tests/test_core.py +++ b/tests/unittests/test_core.py @@ -1,4 +1,7 @@ import unittest +from unittest.mock import MagicMock + +import pytest from .dom_test import DomTest from .dom_tools import node_to_dict @@ -121,5 +124,62 @@ def test_mount_and_redraw(self): ) +class TestCssClass: + def test_cssclass_init(self): + css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red") + + assert len(css_class.rules) == 3 + assert "margin: 10px" in css_class.rules + assert "font-size: 12px" in css_class.rules + assert "color: red" in css_class.rules + assert css_class.class_name.startswith("-ps-") + + def test_cssclass_str(self): + css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red") + class_name_str = str(css_class) + + assert class_name_str == css_class.class_name + + def test_cssclass_render_css(self): + css_class = core.CssClass("margin: 10px", "font-size: 12px", color="red") + rendered_css = css_class.render_css() + + assert rendered_css.startswith(f".{css_class.class_name} {{") + assert "margin: 10px;" in rendered_css + assert "font-size: 12px;" in rendered_css + assert "color: red" in rendered_css + assert rendered_css.endswith(" }") + + +class TestPage: + @pytest.fixture + def page(self): + return core.Page() + + @pytest.fixture + def css_class(self): + return core.CssClass(border="solid 1px blue") + + def test_add_python_css_classes(self, page, css_class): + page.python_css_classes = [css_class] + page.document = MagicMock() + page.document.getElementById.return_value = None + + page.add_python_css_classes() + + page.document.getElementById.assert_called_once_with("puepy-runtime-css") + page.document.createElement.assert_called_once_with("style") + page.document.createTextNode.assert_called_once() + + def test_add_python_css_classes_existing_element(self, page, css_class): + page.python_css_classes = [css_class] + page.document = MagicMock() + + page.add_python_css_classes() + + page.document.getElementById.assert_called_once_with("puepy-runtime-css") + page.document.createElement.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_default_id_generator.py b/tests/unittests/test_default_id_generator.py similarity index 100% rename from tests/test_default_id_generator.py rename to tests/unittests/test_default_id_generator.py diff --git a/tests/test_reactivity.py b/tests/unittests/test_reactivity.py similarity index 100% rename from tests/test_reactivity.py rename to tests/unittests/test_reactivity.py diff --git a/tests/test_router.py b/tests/unittests/test_router.py similarity index 100% rename from tests/test_router.py rename to tests/unittests/test_router.py diff --git a/tests/test_storage.py b/tests/unittests/test_storage.py similarity index 100% rename from tests/test_storage.py rename to tests/unittests/test_storage.py diff --git a/tests/test_utils.py b/tests/unittests/test_utils.py similarity index 93% rename from tests/test_utils.py rename to tests/unittests/test_utils.py index 0202876..d8fe08a 100644 --- a/tests/test_utils.py +++ b/tests/unittests/test_utils.py @@ -1,45 +1,54 @@ import unittest from xml.dom import getDOMImplementation +from puepy import CssClass from puepy.util import merge_classes, _extract_event_handlers, patch_dom_element from .dom_tools import node_to_dict -class TestMergeClasses(unittest.TestCase): +class TestMergeCssClasses(unittest.TestCase): + def test_basic_usage(self): + test1 = CssClass(border="solid 1px silver") + css_classes, runtime_css = merge_classes([test1]) + + self.assertEqual(css_classes.pop(), runtime_css[0].class_name) + + +class TestMergeStringClasses(unittest.TestCase): def test_single_str_input(self): - result = merge_classes("class1") + result = merge_classes("class1")[0] self.assertSetEqual(result, {"class1"}) def test_multiple_str_input(self): - result = merge_classes("class1 class2") + result = merge_classes("class1 class2")[0] self.assertSetEqual(result, {"class1", "class2"}) def test_multiple_different_input(self): - result = merge_classes("class1", ["class2", "class3"]) + result = merge_classes("class1", ["class2", "class3"])[0] self.assertSetEqual(result, {"class1", "class2", "class3"}) def test_dict_input_true_values(self): - result = merge_classes({"class1": True, "class2": True}) + result = merge_classes({"class1": True, "class2": True})[0] self.assertSetEqual(result, {"class1", "class2"}) def test_dict_input_false_values(self): - result = merge_classes({"class1": False, "class2": False}) + result = merge_classes({"class1": False, "class2": False})[0] self.assertSetEqual(result, set()) def test_dict_input_mixed_values(self): - result = merge_classes({"class1": True, "class2": False}) + result = merge_classes({"class1": True, "class2": False})[0] self.assertSetEqual(result, {"class1"}) def test_exclude_class(self): - result = merge_classes("/class1", "class1 class2") + result = merge_classes("/class1", "class1 class2")[0] self.assertEqual(result, {"class2"}) def test_none_input(self): - result = merge_classes(None) + result = merge_classes(None)[0] self.assertSetEqual(result, set()) def test_class_list_input(self): - result = merge_classes(("class1", "class2")) + result = merge_classes(("class1", "class2"))[0] self.assertSetEqual(result, {"class1", "class2"})