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.
Files changed (56) hide show
  1. CHANGELOG.md +11 -0
  2. fastlife/__init__.py +2 -1
  3. fastlife/adapters/jinjax/renderer.py +8 -0
  4. fastlife/adapters/jinjax/widgets/union.py +1 -1
  5. fastlife/adapters/xcomponent/__init__.py +1 -0
  6. fastlife/adapters/xcomponent/catalog.py +11 -0
  7. fastlife/adapters/xcomponent/html/__init__.py +7 -0
  8. fastlife/adapters/xcomponent/html/collapsible.py +76 -0
  9. fastlife/adapters/xcomponent/html/form.py +437 -0
  10. fastlife/adapters/xcomponent/html/nav.py +60 -0
  11. fastlife/adapters/xcomponent/html/table.py +130 -0
  12. fastlife/adapters/xcomponent/html/text.py +30 -0
  13. fastlife/adapters/xcomponent/html/title.py +145 -0
  14. fastlife/adapters/xcomponent/icons/__init__.py +0 -0
  15. fastlife/adapters/xcomponent/icons/icons.py +93 -0
  16. fastlife/adapters/xcomponent/pydantic_form/__init__.py +0 -0
  17. fastlife/adapters/xcomponent/pydantic_form/components.py +121 -0
  18. fastlife/adapters/xcomponent/pydantic_form/widget_factory/__init__.py +1 -0
  19. fastlife/adapters/xcomponent/pydantic_form/widget_factory/base.py +40 -0
  20. fastlife/adapters/xcomponent/pydantic_form/widget_factory/bool_builder.py +45 -0
  21. fastlife/adapters/xcomponent/pydantic_form/widget_factory/emailstr_builder.py +50 -0
  22. fastlife/adapters/xcomponent/pydantic_form/widget_factory/enum_builder.py +49 -0
  23. fastlife/adapters/xcomponent/pydantic_form/widget_factory/factory.py +188 -0
  24. fastlife/adapters/xcomponent/pydantic_form/widget_factory/literal_builder.py +55 -0
  25. fastlife/adapters/xcomponent/pydantic_form/widget_factory/model_builder.py +66 -0
  26. fastlife/adapters/xcomponent/pydantic_form/widget_factory/secretstr_builder.py +48 -0
  27. fastlife/adapters/xcomponent/pydantic_form/widget_factory/sequence_builder.py +60 -0
  28. fastlife/adapters/xcomponent/pydantic_form/widget_factory/set_builder.py +85 -0
  29. fastlife/adapters/xcomponent/pydantic_form/widget_factory/simpletype_builder.py +48 -0
  30. fastlife/adapters/xcomponent/pydantic_form/widget_factory/union_builder.py +92 -0
  31. fastlife/adapters/xcomponent/pydantic_form/widgets/__init__.py +1 -0
  32. fastlife/adapters/xcomponent/pydantic_form/widgets/base.py +140 -0
  33. fastlife/adapters/xcomponent/pydantic_form/widgets/boolean.py +25 -0
  34. fastlife/adapters/xcomponent/pydantic_form/widgets/checklist.py +75 -0
  35. fastlife/adapters/xcomponent/pydantic_form/widgets/dropdown.py +72 -0
  36. fastlife/adapters/xcomponent/pydantic_form/widgets/hidden.py +25 -0
  37. fastlife/adapters/xcomponent/pydantic_form/widgets/mfa_code.py +25 -0
  38. fastlife/adapters/xcomponent/pydantic_form/widgets/model.py +49 -0
  39. fastlife/adapters/xcomponent/pydantic_form/widgets/sequence.py +74 -0
  40. fastlife/adapters/xcomponent/pydantic_form/widgets/text.py +121 -0
  41. fastlife/adapters/xcomponent/pydantic_form/widgets/union.py +81 -0
  42. fastlife/adapters/xcomponent/renderer.py +130 -0
  43. fastlife/assets/dist.css +4 -1
  44. fastlife/components/A.jinja +5 -1
  45. fastlife/config/configurator.py +7 -8
  46. fastlife/config/resources.py +9 -3
  47. fastlife/domain/model/template.py +6 -0
  48. fastlife/service/csrf.py +1 -1
  49. fastlife/service/templates.py +44 -2
  50. fastlife/template_globals.py +3 -0
  51. fastlife/views/pydantic_form.py +9 -9
  52. {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/METADATA +6 -3
  53. {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/RECORD +56 -18
  54. {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/WHEEL +1 -1
  55. {fastlifeweb-0.27.0.dist-info → fastlifeweb-0.28.0.dist-info}/entry_points.txt +0 -0
  56. {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))