fastlifeweb 0.27.0__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CHANGELOG.md +11 -0
- fastlife/__init__.py +2 -1
- fastlife/adapters/jinjax/renderer.py +8 -0
- fastlife/adapters/jinjax/widgets/union.py +1 -1
- fastlife/adapters/xcomponent/__init__.py +1 -0
- fastlife/adapters/xcomponent/catalog.py +11 -0
- fastlife/adapters/xcomponent/html/__init__.py +7 -0
- fastlife/adapters/xcomponent/html/collapsible.py +76 -0
- fastlife/adapters/xcomponent/html/form.py +437 -0
- fastlife/adapters/xcomponent/html/nav.py +60 -0
- fastlife/adapters/xcomponent/html/table.py +130 -0
- fastlife/adapters/xcomponent/html/text.py +30 -0
- fastlife/adapters/xcomponent/html/title.py +145 -0
- fastlife/adapters/xcomponent/icons/__init__.py +0 -0
- fastlife/adapters/xcomponent/icons/icons.py +93 -0
- fastlife/adapters/xcomponent/pydantic_form/__init__.py +0 -0
- fastlife/adapters/xcomponent/pydantic_form/components.py +121 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/__init__.py +1 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/base.py +40 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/bool_builder.py +45 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/emailstr_builder.py +50 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/enum_builder.py +49 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/factory.py +188 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/literal_builder.py +55 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/model_builder.py +66 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/secretstr_builder.py +48 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/sequence_builder.py +60 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/set_builder.py +85 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/simpletype_builder.py +48 -0
- fastlife/adapters/xcomponent/pydantic_form/widget_factory/union_builder.py +92 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/__init__.py +1 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/base.py +140 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/boolean.py +25 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/checklist.py +75 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/dropdown.py +72 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/hidden.py +25 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/mfa_code.py +25 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/model.py +49 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/sequence.py +74 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/text.py +121 -0
- fastlife/adapters/xcomponent/pydantic_form/widgets/union.py +81 -0
- fastlife/adapters/xcomponent/renderer.py +130 -0
- fastlife/assets/dist.css +4 -1
- fastlife/components/A.jinja +5 -1
- fastlife/config/configurator.py +7 -8
- fastlife/config/resources.py +9 -3
- fastlife/domain/model/template.py +6 -0
- fastlife/service/csrf.py +1 -1
- fastlife/service/templates.py +44 -2
- fastlife/template_globals.py +3 -0
- fastlife/views/pydantic_form.py +9 -9
- {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/METADATA +6 -3
- {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/RECORD +56 -18
- {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/WHEEL +1 -1
- {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/entry_points.txt +0 -0
- {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1 @@
|
|
1
|
+
"""HTML Form generation using widgets."""
|
@@ -0,0 +1,140 @@
|
|
1
|
+
"""Widget base class."""
|
2
|
+
|
3
|
+
import secrets
|
4
|
+
from collections.abc import Mapping
|
5
|
+
from typing import Any, Generic, Self, TypeVar
|
6
|
+
|
7
|
+
from markupsafe import Markup
|
8
|
+
from pydantic import Field, model_validator
|
9
|
+
|
10
|
+
from fastlife.domain.model.template import XTemplate
|
11
|
+
from fastlife.service.templates import AbstractTemplateRenderer
|
12
|
+
from fastlife.shared_utils.infer import is_union
|
13
|
+
|
14
|
+
T = TypeVar("T")
|
15
|
+
|
16
|
+
|
17
|
+
def get_title(typ: type[Any]) -> str:
|
18
|
+
return getattr(
|
19
|
+
getattr(typ, "__meta__", None),
|
20
|
+
"title",
|
21
|
+
getattr(typ, "__name__", ""),
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
class Widget(XTemplate, Generic[T]):
|
26
|
+
"""
|
27
|
+
Base class for widget of pydantic fields.
|
28
|
+
|
29
|
+
:param name: field name.
|
30
|
+
:param value: field value.
|
31
|
+
:param title: title for the widget.
|
32
|
+
:param hint: hint for human.
|
33
|
+
:param aria_label: html input aria-label value.
|
34
|
+
:param value: current value.
|
35
|
+
:param error: error of the value if any.
|
36
|
+
:param children_types: childrens types list.
|
37
|
+
:param removable: display a button to remove the widget for optional fields.
|
38
|
+
:param token: token used to get unique id on the form.
|
39
|
+
"""
|
40
|
+
|
41
|
+
name: str
|
42
|
+
"variable name, nested variables have dots."
|
43
|
+
id: str = Field(default="")
|
44
|
+
"variable name, nested variables have dots."
|
45
|
+
value: T | None = Field(default=None)
|
46
|
+
"""Value of the field."""
|
47
|
+
title: str = Field(default="")
|
48
|
+
"Human title for the widget."
|
49
|
+
hint: str | None = Field(default=None)
|
50
|
+
"A help message for the the widget."
|
51
|
+
|
52
|
+
error: str | None = Field(default=None)
|
53
|
+
"Error message."
|
54
|
+
|
55
|
+
aria_label: str | None = Field(default=None)
|
56
|
+
"Non visible text alternative."
|
57
|
+
token: str = Field(default="")
|
58
|
+
"Unique token to ensure id are unique in the DOM."
|
59
|
+
removable: bool = Field(default=False)
|
60
|
+
"Indicate that the widget is removable from the dom."
|
61
|
+
|
62
|
+
@model_validator(mode="after")
|
63
|
+
def fill_props(self) -> Self:
|
64
|
+
self.title = self.title or self.name.split(".")[-1]
|
65
|
+
self.token = self.token or secrets.token_urlsafe(4).replace("_", "-")
|
66
|
+
self.id = self.id or f"{self.name}-{self.token}".replace("_", "-").replace(
|
67
|
+
".", "-"
|
68
|
+
)
|
69
|
+
return self
|
70
|
+
|
71
|
+
def to_html(self, renderer: AbstractTemplateRenderer) -> Markup:
|
72
|
+
"""Return the html version."""
|
73
|
+
return Markup(renderer.render_template(self))
|
74
|
+
|
75
|
+
|
76
|
+
def _get_fullname(typ: type[Any]) -> str:
|
77
|
+
if is_union(typ):
|
78
|
+
typs = [_get_fullname(t) for t in typ.__args__] # type: ignore
|
79
|
+
return "|".join(typs) # type: ignore
|
80
|
+
return f"{typ.__module__}:{typ.__name__}"
|
81
|
+
|
82
|
+
|
83
|
+
TWidget = TypeVar("TWidget", bound=Widget[Any])
|
84
|
+
|
85
|
+
|
86
|
+
class CustomWidget(Generic[TWidget]):
|
87
|
+
typ: type[Any]
|
88
|
+
|
89
|
+
def __init__(self, typ: type[TWidget]) -> None:
|
90
|
+
self.typ = typ
|
91
|
+
|
92
|
+
|
93
|
+
class TypeWrapper:
|
94
|
+
"""
|
95
|
+
Wrap children types for union type.
|
96
|
+
|
97
|
+
:param typ: Wrapped type.
|
98
|
+
:param route_prefix: route prefix used for ajax query to build type.
|
99
|
+
:param name: name of the field wrapped.
|
100
|
+
:param token: unique token to render unique id.
|
101
|
+
:param title: title to display.
|
102
|
+
|
103
|
+
"""
|
104
|
+
|
105
|
+
def __init__(
|
106
|
+
self,
|
107
|
+
typ: type[Any],
|
108
|
+
route_prefix: str,
|
109
|
+
name: str,
|
110
|
+
token: str,
|
111
|
+
title: str | None = None,
|
112
|
+
):
|
113
|
+
self.typ = typ
|
114
|
+
self.route_prefix = route_prefix
|
115
|
+
self.name = name
|
116
|
+
self.title = title or get_title(typ)
|
117
|
+
self.token = token
|
118
|
+
|
119
|
+
@property
|
120
|
+
def fullname(self) -> str:
|
121
|
+
"""Full name for the type."""
|
122
|
+
return _get_fullname(self.typ)
|
123
|
+
|
124
|
+
@property
|
125
|
+
def id(self) -> str:
|
126
|
+
"""Unique id to inject in the DOM."""
|
127
|
+
name = self.name.replace("_", "-").replace(".", "-").replace(":", "-")
|
128
|
+
typ = self.typ.__name__.replace("_", "-")
|
129
|
+
return f"{name}-{typ}-{self.token}"
|
130
|
+
|
131
|
+
@property
|
132
|
+
def params(self) -> Mapping[str, str]:
|
133
|
+
"""Params for the widget to render."""
|
134
|
+
return {"name": self.name, "token": self.token, "title": self.title}
|
135
|
+
|
136
|
+
@property
|
137
|
+
def url(self) -> str:
|
138
|
+
"""Url to fetch the widget."""
|
139
|
+
ret = f"{self.route_prefix}/pydantic-form/widgets/{self.fullname}?format=xcomponent"
|
140
|
+
return ret
|
@@ -0,0 +1,25 @@
|
|
1
|
+
"""
|
2
|
+
Widget for field of type bool.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from .base import Widget
|
6
|
+
|
7
|
+
|
8
|
+
class BooleanWidget(Widget[bool]):
|
9
|
+
"""
|
10
|
+
Widget for field of type bool.
|
11
|
+
"""
|
12
|
+
|
13
|
+
template = """
|
14
|
+
<Widget widget_id={id} removable={removable}>
|
15
|
+
<div class="pt-4">
|
16
|
+
<div class="flex items-center">
|
17
|
+
<Checkbox name={name} id={id} checked={value} value="1" />
|
18
|
+
<Label for={id} class="ms-2 text-base text-neutral-900 dark:text-white">
|
19
|
+
{globals.gettext(title)}
|
20
|
+
</Label>
|
21
|
+
</div>
|
22
|
+
<OptionalErrorText text={error} />
|
23
|
+
</div>
|
24
|
+
</Widget>
|
25
|
+
"""
|
@@ -0,0 +1,75 @@
|
|
1
|
+
"""
|
2
|
+
Widget for field of type Set.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from collections.abc import Sequence
|
6
|
+
from typing import Any, Self
|
7
|
+
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
9
|
+
|
10
|
+
from fastlife.adapters.xcomponent.catalog import catalog
|
11
|
+
|
12
|
+
from .base import Widget
|
13
|
+
|
14
|
+
|
15
|
+
class Checkable(BaseModel):
|
16
|
+
"""A checkable field from a checklist."""
|
17
|
+
|
18
|
+
label: str
|
19
|
+
name: str
|
20
|
+
value: str
|
21
|
+
token: str
|
22
|
+
checked: bool
|
23
|
+
error: str | None = Field(default=None)
|
24
|
+
|
25
|
+
id: str | None = Field(default=None)
|
26
|
+
field_name: str | None = Field(default=None)
|
27
|
+
|
28
|
+
@model_validator(mode="after")
|
29
|
+
def fill_props(self) -> Self:
|
30
|
+
self.id = f"{self.name}-{self.value}-{self.token}".replace(".", "-")
|
31
|
+
self.field_name = f"{self.name}[]"
|
32
|
+
return self
|
33
|
+
|
34
|
+
|
35
|
+
@catalog.component
|
36
|
+
def PydanticFormChecklistItem(value: Checkable, globals: Any) -> str:
|
37
|
+
return """
|
38
|
+
<div class="flex items-center mb-4">
|
39
|
+
<Checkbox name={value.field_name} type="checkbox"
|
40
|
+
id={value.id}
|
41
|
+
value={value.value}
|
42
|
+
checked={value.checked} />
|
43
|
+
<Label for={value.id}
|
44
|
+
class="ms-2 text-base text-neutral-900 dark:text-white">
|
45
|
+
{globals.gettext(value.label)}
|
46
|
+
</Label>
|
47
|
+
<OptionalErrorText text={value.error} />
|
48
|
+
</div>
|
49
|
+
"""
|
50
|
+
|
51
|
+
|
52
|
+
class ChecklistWidget(Widget[Sequence[Checkable]]):
|
53
|
+
"""
|
54
|
+
Widget for field of type Set.
|
55
|
+
"""
|
56
|
+
|
57
|
+
template = """
|
58
|
+
<Widget widget_id={id} removable={removable}>
|
59
|
+
<div class="pt-4">
|
60
|
+
<Details>
|
61
|
+
<Summary id={id + '-summary'}>
|
62
|
+
<H3 class={globals.H3_SUMMARY_CLASS}>{globals.gettext(title)}</H3>
|
63
|
+
<OptionalErrorText text={error} />
|
64
|
+
</Summary>
|
65
|
+
<div>
|
66
|
+
{
|
67
|
+
for v in value {
|
68
|
+
<PydanticFormChecklistItem value={v} />
|
69
|
+
}
|
70
|
+
}
|
71
|
+
</div>
|
72
|
+
</Details>
|
73
|
+
</div>
|
74
|
+
</Widget>
|
75
|
+
"""
|
@@ -0,0 +1,72 @@
|
|
1
|
+
"""
|
2
|
+
Widget for field of type Enum or Literal.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from collections.abc import Sequence
|
6
|
+
|
7
|
+
from pydantic import Field, field_validator
|
8
|
+
from typing_extensions import TypedDict
|
9
|
+
|
10
|
+
from fastlife.adapters.xcomponent.catalog import catalog
|
11
|
+
|
12
|
+
from .base import Widget
|
13
|
+
|
14
|
+
OptionItem = str | int
|
15
|
+
|
16
|
+
|
17
|
+
class Option(TypedDict):
|
18
|
+
value: OptionItem
|
19
|
+
text: OptionItem
|
20
|
+
|
21
|
+
|
22
|
+
@catalog.component
|
23
|
+
def DropDownWidgetOption(id: str, opt: Option) -> str:
|
24
|
+
return """
|
25
|
+
<Option
|
26
|
+
value={opt.value}
|
27
|
+
id={id + "-" + opt.value.replace(" ", " -")}
|
28
|
+
selected={value and value == opt.value}
|
29
|
+
>
|
30
|
+
{globals.gettext(opt.text)}
|
31
|
+
</Option>
|
32
|
+
"""
|
33
|
+
|
34
|
+
|
35
|
+
class DropDownWidget(Widget[str]):
|
36
|
+
"""
|
37
|
+
Widget for field of type Enum or Literal.
|
38
|
+
"""
|
39
|
+
|
40
|
+
template = """
|
41
|
+
<Widget widget_id={id} removable={removable}>
|
42
|
+
<div class="pt-4">
|
43
|
+
<Label for={id}>{ globals.gettext(title) }</Label>
|
44
|
+
<Select name={name} id={id}>
|
45
|
+
{
|
46
|
+
for opt in options {
|
47
|
+
<DropDownWidgetOption id={id} opt={opt} value={value}/>
|
48
|
+
}
|
49
|
+
}
|
50
|
+
</Select>
|
51
|
+
<OptionalErrorText text={error} />
|
52
|
+
<Hint text={hint} />
|
53
|
+
</div>
|
54
|
+
</Widget>
|
55
|
+
"""
|
56
|
+
|
57
|
+
options: list[Option] = Field(default_factory=list)
|
58
|
+
|
59
|
+
@field_validator("options", mode="before")
|
60
|
+
@classmethod
|
61
|
+
def validate_options(
|
62
|
+
cls, options: Sequence[OptionItem | tuple[OptionItem, OptionItem]] | None
|
63
|
+
) -> Sequence[Option]:
|
64
|
+
if not options:
|
65
|
+
return []
|
66
|
+
ret: list[Option] = []
|
67
|
+
for opt in options:
|
68
|
+
if isinstance(opt, tuple):
|
69
|
+
ret.append({"value": opt[0], "text": opt[1]})
|
70
|
+
else:
|
71
|
+
ret.append({"value": opt, "text": opt})
|
72
|
+
return ret
|
@@ -0,0 +1,25 @@
|
|
1
|
+
"""Hidden fields"""
|
2
|
+
|
3
|
+
from fastlife.domain.model.types import Builtins
|
4
|
+
|
5
|
+
from .base import Widget
|
6
|
+
|
7
|
+
|
8
|
+
class HiddenWidget(Widget[Builtins]):
|
9
|
+
'''
|
10
|
+
Widget to annotate to display a field as an hidden field.
|
11
|
+
|
12
|
+
::
|
13
|
+
from pydantic import BaseModel
|
14
|
+
from fastlife.adapters.xcomponent.pydantic_form.widgets.base import CustomWidget
|
15
|
+
from fastlife.adapters.xcomponent.pydantic_form.widgets.hidden import HiddenWidget
|
16
|
+
|
17
|
+
class MyForm(BaseModel):
|
18
|
+
id: Annotated[str, CustomWidget(HiddenWidget)] = Field(...)
|
19
|
+
"""Identifier in the database."""
|
20
|
+
|
21
|
+
'''
|
22
|
+
|
23
|
+
template = """
|
24
|
+
<Hidden name={name} value={value} id={id} />
|
25
|
+
"""
|
@@ -0,0 +1,25 @@
|
|
1
|
+
from pydantic import Field
|
2
|
+
|
3
|
+
from .base import Widget
|
4
|
+
|
5
|
+
|
6
|
+
class MFACodeWidget(Widget[str]):
|
7
|
+
"""
|
8
|
+
Widget for MFA code such as TOTP token.
|
9
|
+
"""
|
10
|
+
|
11
|
+
template = """
|
12
|
+
<Widget widget_id={id} removable={removable}>
|
13
|
+
<div class="pt-4">
|
14
|
+
<Label for={id}>{ globals.gettext(title) }</Label>
|
15
|
+
<OptionalErrorText text={error} />
|
16
|
+
<Input name={name} id={id} inputmode="numeric"
|
17
|
+
autocomplete="one-time-code" autofocus={autofocus}
|
18
|
+
aria-label={aria_label} placeholder={placeholder} />
|
19
|
+
<Hint text={hint} />
|
20
|
+
</div>
|
21
|
+
</Widget>
|
22
|
+
"""
|
23
|
+
|
24
|
+
placeholder: str | None = Field(default=None)
|
25
|
+
autofocus: bool = Field(default=True)
|
@@ -0,0 +1,49 @@
|
|
1
|
+
"""Pydantic models"""
|
2
|
+
|
3
|
+
from collections.abc import Sequence
|
4
|
+
|
5
|
+
from markupsafe import Markup
|
6
|
+
from pydantic import Field
|
7
|
+
|
8
|
+
from fastlife.service.templates import AbstractTemplateRenderer
|
9
|
+
|
10
|
+
from .base import TWidget, Widget
|
11
|
+
|
12
|
+
|
13
|
+
class ModelWidget(Widget[Sequence[TWidget]]):
|
14
|
+
template = """
|
15
|
+
<Widget widget_id={id} removable={removable}>
|
16
|
+
<div id={id} class={if nested {"m-4"}}>
|
17
|
+
{
|
18
|
+
if nested {
|
19
|
+
<Details>
|
20
|
+
<Summary id={id + '-summary'}>
|
21
|
+
<H3 class={globals.H3_SUMMARY_CLASS}>{ globals.gettext(title) }</H3>
|
22
|
+
<OptionalErrorText text={error} />
|
23
|
+
</Summary>
|
24
|
+
<div>
|
25
|
+
{
|
26
|
+
for child in children_widgets {
|
27
|
+
child
|
28
|
+
}
|
29
|
+
}
|
30
|
+
</div>
|
31
|
+
</Details>
|
32
|
+
}
|
33
|
+
else {
|
34
|
+
for child in children_widgets {
|
35
|
+
child
|
36
|
+
}
|
37
|
+
}
|
38
|
+
}
|
39
|
+
</div>
|
40
|
+
</Widget>
|
41
|
+
"""
|
42
|
+
|
43
|
+
nested: bool = Field(default=False)
|
44
|
+
children_widgets: list[str] | None = Field(default=None)
|
45
|
+
|
46
|
+
def to_html(self, renderer: AbstractTemplateRenderer) -> Markup:
|
47
|
+
"""Return the html version."""
|
48
|
+
self.children_widgets = [child.to_html(renderer) for child in self.value or []]
|
49
|
+
return Markup(renderer.render_template(self))
|
@@ -0,0 +1,74 @@
|
|
1
|
+
from collections.abc import Sequence
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
from markupsafe import Markup
|
5
|
+
from pydantic import Field
|
6
|
+
|
7
|
+
from fastlife.service.templates import AbstractTemplateRenderer
|
8
|
+
|
9
|
+
from .base import TWidget, TypeWrapper, Widget
|
10
|
+
|
11
|
+
|
12
|
+
class SequenceWidget(Widget[Sequence[TWidget]]):
|
13
|
+
template = """
|
14
|
+
<Widget widget_id={id} removable={removable}>
|
15
|
+
<Details id={id}>
|
16
|
+
<Summary id={id + '-summary'}>
|
17
|
+
<H3 class={globals.H3_SUMMARY_CLASS}>{ globals.gettext(title) }</H3>
|
18
|
+
<OptionalErrorText text={error} />
|
19
|
+
</Summary>
|
20
|
+
<div>
|
21
|
+
<script>
|
22
|
+
// this function should be added once.
|
23
|
+
function getName(name, id) {
|
24
|
+
const el = document.getElementById(id + '-content');
|
25
|
+
const len = el.dataset.length;
|
26
|
+
el.dataset.length = parseInt(len) + 1;
|
27
|
+
return name + '.' + len;
|
28
|
+
}
|
29
|
+
</script>
|
30
|
+
<div id={id + "-content"} class="m-4"
|
31
|
+
data-length={len(children_widgets)}>
|
32
|
+
{ let container_id = id + "-children-container" }
|
33
|
+
<div id={container_id}>
|
34
|
+
{
|
35
|
+
for child in children_widgets {
|
36
|
+
child
|
37
|
+
}
|
38
|
+
}
|
39
|
+
</div>
|
40
|
+
</div>
|
41
|
+
<div>
|
42
|
+
{ let container_id = "#" + id + "-children-container" }
|
43
|
+
{ let add_id = id + "-add" }
|
44
|
+
{ let vals = 'js:{"name": getName("' + wrapped_type.name + '", "' + id + '")'
|
45
|
+
+ ', "token": "'
|
46
|
+
+ wrapped_type.token + '", "removable": true}'
|
47
|
+
}
|
48
|
+
<Button type="button"
|
49
|
+
hx-target={container_id} hx-swap="beforeend"
|
50
|
+
id={add_id}
|
51
|
+
hx-vals={vals}
|
52
|
+
hx-get={wrapped_type.url}>
|
53
|
+
Add
|
54
|
+
</Button>
|
55
|
+
</div>
|
56
|
+
</div>
|
57
|
+
</Details>
|
58
|
+
</Widget>
|
59
|
+
"""
|
60
|
+
|
61
|
+
item_type: type[Any]
|
62
|
+
wrapped_type: TypeWrapper | None = Field(default=None)
|
63
|
+
children_widgets: list[str] | None = Field(default=None)
|
64
|
+
|
65
|
+
def build_item_type(self, route_prefix: str) -> TypeWrapper:
|
66
|
+
return TypeWrapper(self.item_type, route_prefix, self.name, self.token)
|
67
|
+
|
68
|
+
def to_html(self, renderer: "AbstractTemplateRenderer") -> Markup:
|
69
|
+
"""Return the html version."""
|
70
|
+
self.wrapped_type = self.build_item_type(renderer.route_prefix)
|
71
|
+
self.children_widgets = [
|
72
|
+
Markup(item.to_html(renderer)) for item in self.value or []
|
73
|
+
]
|
74
|
+
return Markup(renderer.render_template(self))
|
@@ -0,0 +1,121 @@
|
|
1
|
+
from collections.abc import Sequence
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
from pydantic import Field, SecretStr
|
5
|
+
|
6
|
+
from fastlife.adapters.xcomponent.catalog import catalog
|
7
|
+
from fastlife.domain.model.types import Builtins
|
8
|
+
|
9
|
+
from .base import Widget
|
10
|
+
|
11
|
+
|
12
|
+
@catalog.function
|
13
|
+
def is_str(value: Any) -> bool:
|
14
|
+
return isinstance(value, str)
|
15
|
+
|
16
|
+
|
17
|
+
class TextWidget(Widget[Builtins]):
|
18
|
+
"""
|
19
|
+
Widget for text like field (email, ...).
|
20
|
+
"""
|
21
|
+
|
22
|
+
template = """
|
23
|
+
<Widget widget_id={id} removable={removable}>
|
24
|
+
<div class="pt-4">
|
25
|
+
<Label for={id}>{globals.gettext(title)}</Label>
|
26
|
+
<OptionalErrorText text={error} />
|
27
|
+
<Input name={name} value={value} type={input_type} id={id}
|
28
|
+
aria-label={aria_label} placeholder={placeholder}
|
29
|
+
autocomplete={autocomplete} />
|
30
|
+
<Hint text={hint} />
|
31
|
+
</div>
|
32
|
+
</Widget>
|
33
|
+
"""
|
34
|
+
|
35
|
+
input_type: str = Field(default="text")
|
36
|
+
"""type attribute for the Input component."""
|
37
|
+
placeholder: str | None = Field(default=None)
|
38
|
+
"""placeholder attribute for the Input component."""
|
39
|
+
autocomplete: str | None = Field(default=None)
|
40
|
+
"""autocomplete attribute for the Input component."""
|
41
|
+
|
42
|
+
|
43
|
+
class PasswordWidget(Widget[SecretStr]):
|
44
|
+
"""
|
45
|
+
Widget for password fields.
|
46
|
+
"""
|
47
|
+
|
48
|
+
template = """
|
49
|
+
<Widget widget_id={id} removable={removable}>
|
50
|
+
<div class="pt-4">
|
51
|
+
<Label for={id}>{globals.gettext(title)}</Label>
|
52
|
+
<OptionalErrorText text={error} />
|
53
|
+
<Password name={name} type={input_type} id={id}
|
54
|
+
autocomplete={
|
55
|
+
if new_password {
|
56
|
+
<>new-password</>
|
57
|
+
}
|
58
|
+
else {
|
59
|
+
<>current-password</>
|
60
|
+
}
|
61
|
+
}
|
62
|
+
aria-label={aria_label} placeholder={placeholder} />
|
63
|
+
<Hint text={hint} />
|
64
|
+
</div>
|
65
|
+
</Widget>
|
66
|
+
"""
|
67
|
+
|
68
|
+
input_type: str = Field(default="password")
|
69
|
+
"""type attribute for the Input component."""
|
70
|
+
placeholder: str | None = Field(default=None)
|
71
|
+
"""placeholder attribute for the Input component."""
|
72
|
+
new_password: bool = Field(default=False)
|
73
|
+
"""
|
74
|
+
Adapt autocomplete behavior for browsers to hint existing or generate password.
|
75
|
+
"""
|
76
|
+
|
77
|
+
|
78
|
+
class TextareaWidget(Widget[str | Sequence[str]]):
|
79
|
+
"""
|
80
|
+
Render a Textearea for a string or event a sequence of string.
|
81
|
+
|
82
|
+
```
|
83
|
+
from fastlife.adapters.jinjax.widgets.base import CustomWidget
|
84
|
+
from fastlife.adapters.jinjax.widgets.text import TextareaWidget
|
85
|
+
from pydantic import BaseModel, Field, field_validator
|
86
|
+
|
87
|
+
class TaggedParagraphForm(BaseModel):
|
88
|
+
paragraph: Annotated[str, CustomWidget(TextareaWidget)] = Field(...)
|
89
|
+
tags: Annotated[Sequence[str], CustomWidget(TextareaWidget)] = Field(
|
90
|
+
default_factory=list,
|
91
|
+
title="Tags",
|
92
|
+
description="One tag per line",
|
93
|
+
)
|
94
|
+
|
95
|
+
@field_validator("tags", mode="before")
|
96
|
+
def split(cls, s: Any) -> Sequence[str]:
|
97
|
+
return s.split() if s else []
|
98
|
+
```
|
99
|
+
"""
|
100
|
+
|
101
|
+
template = """
|
102
|
+
<Widget widget_id={id} removable={removable}>
|
103
|
+
<div class="pt-4">
|
104
|
+
<Label for={id}>{globals.gettext(title)}</Label>
|
105
|
+
<OptionalErrorText text={error} />
|
106
|
+
<Textarea name={name} id={id} aria-label={aria_label}>
|
107
|
+
{
|
108
|
+
if is_str(value) {
|
109
|
+
value
|
110
|
+
}
|
111
|
+
else {
|
112
|
+
for v in value {
|
113
|
+
v + "\n"
|
114
|
+
}
|
115
|
+
}
|
116
|
+
}
|
117
|
+
</Textarea>
|
118
|
+
<Hint text={hint} />
|
119
|
+
</div>
|
120
|
+
</Widget>
|
121
|
+
"""
|
@@ -0,0 +1,81 @@
|
|
1
|
+
"""
|
2
|
+
Widget for field of type Union.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from collections.abc import Sequence
|
6
|
+
from typing import Union
|
7
|
+
|
8
|
+
from markupsafe import Markup
|
9
|
+
from pydantic import BaseModel, Field
|
10
|
+
|
11
|
+
from fastlife.service.templates import AbstractTemplateRenderer
|
12
|
+
|
13
|
+
from .base import TWidget, TypeWrapper, Widget
|
14
|
+
|
15
|
+
|
16
|
+
class UnionWidget(Widget[TWidget]):
|
17
|
+
"""
|
18
|
+
Widget for union types.
|
19
|
+
"""
|
20
|
+
|
21
|
+
template = """
|
22
|
+
<Widget widget_id={id} removable={removable}>
|
23
|
+
<div id={id}>
|
24
|
+
<Details>
|
25
|
+
<Summary id={id + '-union-summary'}>
|
26
|
+
<H3 class={globals.H3_SUMMARY_CLASS}>{ globals.gettext(title) }</H3>
|
27
|
+
<OptionalErrorText text={error} />
|
28
|
+
</Summary>
|
29
|
+
<div hx-sync="this" id="{{id}}-child">
|
30
|
+
{
|
31
|
+
if child {
|
32
|
+
child
|
33
|
+
}
|
34
|
+
else {
|
35
|
+
for typ in types {
|
36
|
+
<Button type="button"
|
37
|
+
hx-target="closest div"
|
38
|
+
hx-get={typ.url}
|
39
|
+
hx-vals={typ.params}
|
40
|
+
id={typ.id}
|
41
|
+
onclick={"document.getElementById('" + id + "-remove-btn').hidden=false"}
|
42
|
+
class={globals.SECONDARY_BUTTON_CLASS}>{globals.gettext(typ.title)}</Button>
|
43
|
+
}
|
44
|
+
}
|
45
|
+
}
|
46
|
+
</div>
|
47
|
+
<Button type="button" id={id + '-remove-btn'} hx-target={'#' + id}
|
48
|
+
hx-vals={parent_type.params} hx-get={parent_type.url} hidden={not child}
|
49
|
+
class={globals.SECONDARY_BUTTON_CLASS}>
|
50
|
+
Remove
|
51
|
+
</Button>
|
52
|
+
</Details>
|
53
|
+
</div>
|
54
|
+
</Widget>
|
55
|
+
"""
|
56
|
+
|
57
|
+
children_types: Sequence[type[BaseModel]]
|
58
|
+
parent_type: TypeWrapper | None = Field(default=None)
|
59
|
+
|
60
|
+
types: Sequence[TypeWrapper] | None = Field(default=None)
|
61
|
+
child: str = Field(default="")
|
62
|
+
|
63
|
+
def build_types(self, route_prefix: str) -> Sequence[TypeWrapper]:
|
64
|
+
"""Wrap types in the union in order to get the in their own widgets."""
|
65
|
+
return [
|
66
|
+
TypeWrapper(typ, route_prefix, self.name, self.token)
|
67
|
+
for typ in self.children_types
|
68
|
+
]
|
69
|
+
|
70
|
+
def to_html(self, renderer: "AbstractTemplateRenderer") -> Markup:
|
71
|
+
"""Return the html version."""
|
72
|
+
self.child = Markup(self.value.to_html(renderer)) if self.value else ""
|
73
|
+
self.types = self.build_types(renderer.route_prefix)
|
74
|
+
self.parent_type = TypeWrapper(
|
75
|
+
Union[tuple(self.children_types)], # type: ignore # noqa: UP007
|
76
|
+
renderer.route_prefix,
|
77
|
+
self.name,
|
78
|
+
self.token,
|
79
|
+
title=self.title,
|
80
|
+
)
|
81
|
+
return Markup(renderer.render_template(self))
|