Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Serialization for the Navbar tools #9

Merged
merged 1 commit into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/api/bootlace.table.EditColumn.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
EditColumn
==========

.. currentmodule:: bootlace.table

.. autoclass:: EditColumn
:show-inheritance:

.. rubric:: Attributes Summary

.. autosummary::

~EditColumn.attribute
~EditColumn.endpoint
~EditColumn.heading

.. rubric:: Methods Summary

.. autosummary::

~EditColumn.cell

.. rubric:: Attributes Documentation

.. autoattribute:: attribute
.. autoattribute:: endpoint
.. autoattribute:: heading

.. rubric:: Methods Documentation

.. automethod:: cell
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test-all:

# Run lints
lint:
flake8
pre-commit run --all-files

# Run mypy
mypy:
Expand Down
9 changes: 6 additions & 3 deletions src/bootlace/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
class LinkBase(abc.ABC):
text: MaybeTaggable

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

@abc.abstractproperty
@property
@abc.abstractmethod
def enabled(self) -> bool:
raise NotImplementedError("LinkBase.enabled must be implemented in a subclass")

@abc.abstractproperty
@property
@abc.abstractmethod
def url(self) -> str:
raise NotImplementedError("LinkBase.url must be implemented in a subclass")

Expand Down
16 changes: 16 additions & 0 deletions src/bootlace/nav/bar.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

import attrs
from dominate import tags
from dominate.dom_tag import dom_tag
Expand Down Expand Up @@ -36,6 +38,20 @@ class NavBar(NavElement):
#: Whether the navbar should be fluid (e.g. full width)
fluid: bool = True

def serialize(self) -> dict[str, Any]:
data = super().serialize()
data["items"] = [item.serialize() for item in self.items]
data["expand"] = self.expand.value if self.expand else None
data["color"] = self.color.value if self.color else None
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> NavElement:
data["items"] = [NavElement.deserialize(item) for item in data["items"]]
data["expand"] = SizeClass(data["expand"]) if data["expand"] else None
data["color"] = ColorClass(data["color"]) if data["color"] else None
return cls(**data)

def __tag__(self) -> tags.html_tag:
nav = tags.nav(cls="navbar")
if self.expand:
Expand Down
43 changes: 43 additions & 0 deletions src/bootlace/nav/core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import enum
import warnings
from typing import Any
from typing import Self

import attrs
from dominate import tags
Expand Down Expand Up @@ -33,6 +34,26 @@ class NavAlignment(enum.Enum):
class NavElement:
"""Base class for nav components"""

_NAV_ELEMENT_REGISTRY: dict[str, type["NavElement"]] = {}

def __init_subclass__(cls) -> None:
cls._NAV_ELEMENT_REGISTRY[cls.__name__] = cls

def serialize(self) -> dict[str, Any]:
"""Serialize the element to a dictionary"""
data = attrs.asdict(self) # type: ignore
data["__type__"] = self.__class__.__name__
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> "NavElement":
"""Deserialize an element from a dictionary"""
if cls is NavElement:
element_cls = cls._NAV_ELEMENT_REGISTRY.get(data["__type__"], NavElement)
del data["__type__"]
return element_cls.deserialize(data)
return cls(**data)

@property
def active(self) -> bool:
"""Whether the element is active"""
Expand Down Expand Up @@ -72,6 +93,18 @@ class Link(NavElement):
#: The ID of the element
id: str = attrs.field(factory=element_id.factory("nav-link"))

def serialize(self) -> dict[str, Any]:
data = super().serialize()
data["link"] = attrs.asdict(self.link)
data["link"]["__type__"] = self.link.__class__.__name__
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> Self:
link_cls = getattr(links, data["link"].pop("__type__"))
data["link"] = link_cls(**data["link"])
return cls(**data)

@classmethod
def with_url(cls, url: str, text: str | Image, **kwargs: Any) -> "Link":
"""Create a link with a URL."""
Expand Down Expand Up @@ -135,6 +168,16 @@ class SubGroup(NavElement):

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

def serialize(self) -> dict[str, Any]:
data = super().serialize()
data["items"] = [item.serialize() for item in self.items]
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> Self:
data["items"] = [NavElement.deserialize(item) for item in data["items"]]
return cls(**data)

@property
def active(self) -> bool:
return any(item.active for item in self.items)
14 changes: 14 additions & 0 deletions src/bootlace/nav/nav.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import warnings
from typing import Any
from typing import Self

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

def serialize(self) -> dict[str, Any]:
data = super().serialize()
data["style"] = self.style.name
data["alignment"] = self.alignment.name
return data

@classmethod
def deserialize(cls, data: dict[str, Any]) -> Self:
data["style"] = NavStyle[data["style"]]
data["alignment"] = NavAlignment[data["alignment"]]
return super().deserialize(data)

def __tag__(self) -> tags.html_tag:
active_endpoint = next((item for item in self.items if item.active), None)
ul = tags.ul(cls="nav", id=self.id)
Expand Down
3 changes: 2 additions & 1 deletion src/bootlace/table/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from .columns import CheckColumn
from .columns import Column
from .columns import Datetime
from .columns import EditColumn

__all__ = ["Table", "ColumnBase", "Heading", "Column", "CheckColumn", "Datetime"]
__all__ = ["Table", "ColumnBase", "Heading", "Column", "CheckColumn", "Datetime", "EditColumn"]
11 changes: 10 additions & 1 deletion src/bootlace/table/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,17 @@ def cell(self, value: Any) -> dom_tag:

@attrs.define
class Datetime(ColumnBase):
"""A column which shows a datetime attribute as an ISO formatted string."""
"""A column which shows a datetime attribute as an ISO formatted string.

This column can also be used for date or time objects.

A format string can be provided to format the datetime object."""

format: str | None = attrs.field(default=None)

def cell(self, value: Any) -> dom_tag:
"""Return the cell for the column as an HTML tag."""
if self.format:
return text(getattr(value, self.attribute).strftime(self.format))

return text(getattr(value, self.attribute).isoformat())
3 changes: 3 additions & 0 deletions src/bootlace/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ def __call__(self, scope: str) -> str:
def factory(self, scope: str) -> functools.partial:
return functools.partial(self, scope)

def reset(self) -> None:
self.scopes.clear()


ids = HtmlIDScope()

Expand Down
38 changes: 38 additions & 0 deletions tests/nav/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from pathlib import Path

import pytest

from bootlace.nav import bar
from bootlace.nav import elements
from bootlace.util import ids


def get_fixture(name: str) -> str:
Expand All @@ -24,3 +28,37 @@ class DisabledLink(elements.Link):
@property
def enabled(self) -> bool:
return False


@pytest.fixture
def nav() -> bar.Nav:
ids.reset()

nav = bar.NavBar(
items=[
bar.Brand.with_url(url="#", text="Navbar"),
bar.NavBarCollapse(
id="navbarSupportedContent",
items=[
bar.NavBarNav(
items=[
CurrentLink.with_url(url="#", text="Home"),
elements.Link.with_url(url="#", text="Link"),
elements.Dropdown(
title="Dropdown",
items=[
elements.Link.with_url(url="#", text="Action"),
elements.Link.with_url(url="#", text="Another action"),
elements.Separator(),
elements.Link.with_url(url="#", text="Separated link"),
],
),
DisabledLink.with_url(url="#", text="Disabled"),
]
),
bar.NavBarSearch(),
],
),
],
)
return nav
26 changes: 11 additions & 15 deletions tests/nav/test_nav.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from .conftest import CurrentLink
from .conftest import DisabledLink
from .conftest import get_fixture
Expand All @@ -7,43 +9,37 @@
from bootlace.util import render


def test_base_nav() -> None:

@pytest.fixture
def nav() -> elements.Nav:
nav = elements.Nav()
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
return nav


def test_base_nav(nav: elements.Nav) -> None:
source = render(nav)

expected = get_fixture("nav.html")

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


def test_nav_tabs() -> None:

nav = elements.Nav(style=NavStyle.TABS)
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
def test_nav_tabs(nav: elements.Nav) -> None:

nav.style = NavStyle.TABS
source = render(nav)

expected = get_fixture("nav_tabs.html")

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


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

nav = elements.Nav(style=NavStyle.PILLS)
nav.items.append(CurrentLink.with_url(url="#", text="Active"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(elements.Link.with_url(url="#", text="Link"))
nav.items.append(DisabledLink.with_url(url="#", text="Disabled"))
nav.style = NavStyle.PILLS

source = render(nav)

Expand Down
29 changes: 1 addition & 28 deletions tests/nav/test_navbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,7 @@
from bootlace.util import render


def test_navbar() -> None:
nav = bar.NavBar(
items=[
bar.Brand.with_url(url="#", text="Navbar"),
bar.NavBarCollapse(
id="navbarSupportedContent",
items=[
bar.NavBarNav(
items=[
CurrentLink.with_url(url="#", text="Home"),
elements.Link.with_url(url="#", text="Link"),
elements.Dropdown(
title="Dropdown",
items=[
elements.Link.with_url(url="#", text="Action"),
elements.Link.with_url(url="#", text="Another action"),
elements.Separator(),
elements.Link.with_url(url="#", text="Separated link"),
],
),
DisabledLink.with_url(url="#", text="Disabled"),
]
),
bar.NavBarSearch(),
],
),
],
)
def test_navbar(nav: bar.NavBar) -> None:

source = render(nav)

Expand Down
Loading
Loading