Skip to content

Commit 6101c03

Browse files
committed
Serialization for the Navbar tools
1 parent 2809406 commit 6101c03

13 files changed

+220
-49
lines changed
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
EditColumn
2+
==========
3+
4+
.. currentmodule:: bootlace.table
5+
6+
.. autoclass:: EditColumn
7+
:show-inheritance:
8+
9+
.. rubric:: Attributes Summary
10+
11+
.. autosummary::
12+
13+
~EditColumn.attribute
14+
~EditColumn.endpoint
15+
~EditColumn.heading
16+
17+
.. rubric:: Methods Summary
18+
19+
.. autosummary::
20+
21+
~EditColumn.cell
22+
23+
.. rubric:: Attributes Documentation
24+
25+
.. autoattribute:: attribute
26+
.. autoattribute:: endpoint
27+
.. autoattribute:: heading
28+
29+
.. rubric:: Methods Documentation
30+
31+
.. automethod:: cell

justfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test-all:
2525

2626
# Run lints
2727
lint:
28-
flake8
28+
pre-commit run --all-files
2929

3030
# Run mypy
3131
mypy:

src/bootlace/links.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,18 @@
1616
class LinkBase(abc.ABC):
1717
text: MaybeTaggable
1818

19-
@abc.abstractproperty
19+
@property
20+
@abc.abstractmethod
2021
def active(self) -> bool:
2122
raise NotImplementedError("LinkBase.active must be implemented in a subclass")
2223

23-
@abc.abstractproperty
24+
@property
25+
@abc.abstractmethod
2426
def enabled(self) -> bool:
2527
raise NotImplementedError("LinkBase.enabled must be implemented in a subclass")
2628

27-
@abc.abstractproperty
29+
@property
30+
@abc.abstractmethod
2831
def url(self) -> str:
2932
raise NotImplementedError("LinkBase.url must be implemented in a subclass")
3033

src/bootlace/nav/bar.py

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
import attrs
24
from dominate import tags
35
from dominate.dom_tag import dom_tag
@@ -36,6 +38,20 @@ class NavBar(NavElement):
3638
#: Whether the navbar should be fluid (e.g. full width)
3739
fluid: bool = True
3840

41+
def serialize(self) -> dict[str, Any]:
42+
data = super().serialize()
43+
data["items"] = [item.serialize() for item in self.items]
44+
data["expand"] = self.expand.value if self.expand else None
45+
data["color"] = self.color.value if self.color else None
46+
return data
47+
48+
@classmethod
49+
def deserialize(cls, data: dict[str, Any]) -> NavElement:
50+
data["items"] = [NavElement.deserialize(item) for item in data["items"]]
51+
data["expand"] = SizeClass(data["expand"]) if data["expand"] else None
52+
data["color"] = ColorClass(data["color"]) if data["color"] else None
53+
return cls(**data)
54+
3955
def __tag__(self) -> tags.html_tag:
4056
nav = tags.nav(cls="navbar")
4157
if self.expand:

src/bootlace/nav/core.py

+43
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import enum
22
import warnings
33
from typing import Any
4+
from typing import Self
45

56
import attrs
67
from dominate import tags
@@ -33,6 +34,26 @@ class NavAlignment(enum.Enum):
3334
class NavElement:
3435
"""Base class for nav components"""
3536

37+
_NAV_ELEMENT_REGISTRY: dict[str, type["NavElement"]] = {}
38+
39+
def __init_subclass__(cls) -> None:
40+
cls._NAV_ELEMENT_REGISTRY[cls.__name__] = cls
41+
42+
def serialize(self) -> dict[str, Any]:
43+
"""Serialize the element to a dictionary"""
44+
data = attrs.asdict(self) # type: ignore
45+
data["__type__"] = self.__class__.__name__
46+
return data
47+
48+
@classmethod
49+
def deserialize(cls, data: dict[str, Any]) -> "NavElement":
50+
"""Deserialize an element from a dictionary"""
51+
if cls is NavElement:
52+
element_cls = cls._NAV_ELEMENT_REGISTRY.get(data["__type__"], NavElement)
53+
del data["__type__"]
54+
return element_cls.deserialize(data)
55+
return cls(**data)
56+
3657
@property
3758
def active(self) -> bool:
3859
"""Whether the element is active"""
@@ -72,6 +93,18 @@ class Link(NavElement):
7293
#: The ID of the element
7394
id: str = attrs.field(factory=element_id.factory("nav-link"))
7495

96+
def serialize(self) -> dict[str, Any]:
97+
data = super().serialize()
98+
data["link"] = attrs.asdict(self.link)
99+
data["link"]["__type__"] = self.link.__class__.__name__
100+
return data
101+
102+
@classmethod
103+
def deserialize(cls, data: dict[str, Any]) -> Self:
104+
link_cls = getattr(links, data["link"].pop("__type__"))
105+
data["link"] = link_cls(**data["link"])
106+
return cls(**data)
107+
75108
@classmethod
76109
def with_url(cls, url: str, text: str | Image, **kwargs: Any) -> "Link":
77110
"""Create a link with a URL."""
@@ -135,6 +168,16 @@ class SubGroup(NavElement):
135168

136169
items: list[NavElement] = attrs.field(factory=list)
137170

171+
def serialize(self) -> dict[str, Any]:
172+
data = super().serialize()
173+
data["items"] = [item.serialize() for item in self.items]
174+
return data
175+
176+
@classmethod
177+
def deserialize(cls, data: dict[str, Any]) -> Self:
178+
data["items"] = [NavElement.deserialize(item) for item in data["items"]]
179+
return cls(**data)
180+
138181
@property
139182
def active(self) -> bool:
140183
return any(item.active for item in self.items)

src/bootlace/nav/nav.py

+14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import warnings
2+
from typing import Any
3+
from typing import Self
24

35
import attrs
46
from dominate import tags
@@ -24,6 +26,18 @@ class Nav(SubGroup):
2426
#: The alignment of the elments in the nav
2527
alignment: NavAlignment = NavAlignment.DEFAULT
2628

29+
def serialize(self) -> dict[str, Any]:
30+
data = super().serialize()
31+
data["style"] = self.style.name
32+
data["alignment"] = self.alignment.name
33+
return data
34+
35+
@classmethod
36+
def deserialize(cls, data: dict[str, Any]) -> Self:
37+
data["style"] = NavStyle[data["style"]]
38+
data["alignment"] = NavAlignment[data["alignment"]]
39+
return super().deserialize(data)
40+
2741
def __tag__(self) -> tags.html_tag:
2842
active_endpoint = next((item for item in self.items if item.active), None)
2943
ul = tags.ul(cls="nav", id=self.id)

src/bootlace/table/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
from .columns import CheckColumn
55
from .columns import Column
66
from .columns import Datetime
7+
from .columns import EditColumn
78

8-
__all__ = ["Table", "ColumnBase", "Heading", "Column", "CheckColumn", "Datetime"]
9+
__all__ = ["Table", "ColumnBase", "Heading", "Column", "CheckColumn", "Datetime", "EditColumn"]

src/bootlace/table/columns.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,17 @@ def cell(self, value: Any) -> dom_tag:
5959

6060
@attrs.define
6161
class Datetime(ColumnBase):
62-
"""A column which shows a datetime attribute as an ISO formatted string."""
62+
"""A column which shows a datetime attribute as an ISO formatted string.
63+
64+
This column can also be used for date or time objects.
65+
66+
A format string can be provided to format the datetime object."""
67+
68+
format: str | None = attrs.field(default=None)
6369

6470
def cell(self, value: Any) -> dom_tag:
6571
"""Return the cell for the column as an HTML tag."""
72+
if self.format:
73+
return text(getattr(value, self.attribute).strftime(self.format))
74+
6675
return text(getattr(value, self.attribute).isoformat())

src/bootlace/util.py

+3
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,9 @@ def __call__(self, scope: str) -> str:
180180
def factory(self, scope: str) -> functools.partial:
181181
return functools.partial(self, scope)
182182

183+
def reset(self) -> None:
184+
self.scopes.clear()
185+
183186

184187
ids = HtmlIDScope()
185188

tests/nav/conftest.py

+38
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from pathlib import Path
22

3+
import pytest
4+
5+
from bootlace.nav import bar
36
from bootlace.nav import elements
7+
from bootlace.util import ids
48

59

610
def get_fixture(name: str) -> str:
@@ -24,3 +28,37 @@ class DisabledLink(elements.Link):
2428
@property
2529
def enabled(self) -> bool:
2630
return False
31+
32+
33+
@pytest.fixture
34+
def nav() -> bar.Nav:
35+
ids.reset()
36+
37+
nav = bar.NavBar(
38+
items=[
39+
bar.Brand.with_url(url="#", text="Navbar"),
40+
bar.NavBarCollapse(
41+
id="navbarSupportedContent",
42+
items=[
43+
bar.NavBarNav(
44+
items=[
45+
CurrentLink.with_url(url="#", text="Home"),
46+
elements.Link.with_url(url="#", text="Link"),
47+
elements.Dropdown(
48+
title="Dropdown",
49+
items=[
50+
elements.Link.with_url(url="#", text="Action"),
51+
elements.Link.with_url(url="#", text="Another action"),
52+
elements.Separator(),
53+
elements.Link.with_url(url="#", text="Separated link"),
54+
],
55+
),
56+
DisabledLink.with_url(url="#", text="Disabled"),
57+
]
58+
),
59+
bar.NavBarSearch(),
60+
],
61+
),
62+
],
63+
)
64+
return nav

tests/nav/test_nav.py

+11-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
from .conftest import CurrentLink
24
from .conftest import DisabledLink
35
from .conftest import get_fixture
@@ -7,43 +9,37 @@
79
from bootlace.util import render
810

911

10-
def test_base_nav() -> None:
11-
12+
@pytest.fixture
13+
def nav() -> elements.Nav:
1214
nav = elements.Nav()
1315
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
1416
nav.items.append(elements.Link.with_url(url="#", text="Link"))
1517
nav.items.append(elements.Link.with_url(url="#", text="Link"))
1618
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
19+
return nav
20+
1721

22+
def test_base_nav(nav: elements.Nav) -> None:
1823
source = render(nav)
1924

2025
expected = get_fixture("nav.html")
2126

2227
assert_same_html(expected_html=expected, actual_html=str(source))
2328

2429

25-
def test_nav_tabs() -> None:
26-
27-
nav = elements.Nav(style=NavStyle.TABS)
28-
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
29-
nav.items.append(elements.Link.with_url(url="#", text="Link"))
30-
nav.items.append(elements.Link.with_url(url="#", text="Link"))
31-
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
30+
def test_nav_tabs(nav: elements.Nav) -> None:
3231

32+
nav.style = NavStyle.TABS
3333
source = render(nav)
3434

3535
expected = get_fixture("nav_tabs.html")
3636

3737
assert_same_html(expected_html=expected, actual_html=str(source))
3838

3939

40-
def test_nav_pills() -> None:
40+
def test_nav_pills(nav: elements.Nav) -> None:
4141

42-
nav = elements.Nav(style=NavStyle.PILLS)
43-
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
44-
nav.items.append(elements.Link.with_url(url="#", text="Link"))
45-
nav.items.append(elements.Link.with_url(url="#", text="Link"))
46-
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
42+
nav.style = NavStyle.PILLS
4743

4844
source = render(nav)
4945

tests/nav/test_navbar.py

+1-28
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,7 @@
99
from bootlace.util import render
1010

1111

12-
def test_navbar() -> None:
13-
nav = bar.NavBar(
14-
items=[
15-
bar.Brand.with_url(url="#", text="Navbar"),
16-
bar.NavBarCollapse(
17-
id="navbarSupportedContent",
18-
items=[
19-
bar.NavBarNav(
20-
items=[
21-
CurrentLink.with_url(url="#", text="Home"),
22-
elements.Link.with_url(url="#", text="Link"),
23-
elements.Dropdown(
24-
title="Dropdown",
25-
items=[
26-
elements.Link.with_url(url="#", text="Action"),
27-
elements.Link.with_url(url="#", text="Another action"),
28-
elements.Separator(),
29-
elements.Link.with_url(url="#", text="Separated link"),
30-
],
31-
),
32-
DisabledLink.with_url(url="#", text="Disabled"),
33-
]
34-
),
35-
bar.NavBarSearch(),
36-
],
37-
),
38-
],
39-
)
12+
def test_navbar(nav: bar.NavBar) -> None:
4013

4114
source = render(nav)
4215

0 commit comments

Comments
 (0)