almasix-orbit-forms 0.1.0__tar.gz
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.
- almasix_orbit_forms-0.1.0/.gitignore +29 -0
- almasix_orbit_forms-0.1.0/PKG-INFO +17 -0
- almasix_orbit_forms-0.1.0/README.md +3 -0
- almasix_orbit_forms-0.1.0/pyproject.toml +24 -0
- almasix_orbit_forms-0.1.0/src/almasix/__init__.py +2 -0
- almasix_orbit_forms-0.1.0/src/almasix/orbit/__init__.py +2 -0
- almasix_orbit_forms-0.1.0/src/almasix/orbit/forms/__init__.py +69 -0
- almasix_orbit_forms-0.1.0/src/almasix/orbit/forms/components.py +377 -0
- almasix_orbit_forms-0.1.0/src/almasix/orbit/forms/form.py +78 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.eggs/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
.env
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
|
|
18
|
+
# Node / docs
|
|
19
|
+
node_modules/
|
|
20
|
+
docs/dist/
|
|
21
|
+
docs/.astro/
|
|
22
|
+
assets/node_modules/
|
|
23
|
+
assets/dist/
|
|
24
|
+
|
|
25
|
+
# OS / IDE
|
|
26
|
+
.DS_Store
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
29
|
+
*.swp
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: almasix-orbit-forms
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Orbit form fields and Form builder
|
|
5
|
+
Project-URL: Homepage, https://github.com/almasix-dev/almasix-orbit
|
|
6
|
+
Project-URL: Documentation, https://orbit.almasix.com/
|
|
7
|
+
Project-URL: Repository, https://github.com/almasix-dev/almasix-orbit
|
|
8
|
+
Author: Almasix Contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: almasix-orbit-schemas>=0.1.0
|
|
12
|
+
Requires-Dist: almasix-orbit-support>=0.1.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# almasix-orbit-forms
|
|
16
|
+
|
|
17
|
+
Filament-style form builder and field components for Orbit.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "almasix-orbit-forms"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Orbit form fields and Form builder"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Almasix Contributors" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"almasix-orbit-schemas>=0.1.0",
|
|
15
|
+
"almasix-orbit-support>=0.1.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/almasix-dev/almasix-orbit"
|
|
20
|
+
Documentation = "https://orbit.almasix.com/"
|
|
21
|
+
Repository = "https://github.com/almasix-dev/almasix-orbit"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/almasix"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from almasix.orbit.forms.components import (
|
|
2
|
+
Builder,
|
|
3
|
+
Checkbox,
|
|
4
|
+
CheckboxList,
|
|
5
|
+
CodeEditor,
|
|
6
|
+
ColorPicker,
|
|
7
|
+
DatePicker,
|
|
8
|
+
DateTimePicker,
|
|
9
|
+
Field,
|
|
10
|
+
FileUpload,
|
|
11
|
+
Hidden,
|
|
12
|
+
KeyValue,
|
|
13
|
+
MarkdownEditor,
|
|
14
|
+
ModalTableSelect,
|
|
15
|
+
MorphToSelect,
|
|
16
|
+
MultiSelect,
|
|
17
|
+
OneTimeCodeInput,
|
|
18
|
+
Placeholder,
|
|
19
|
+
Radio,
|
|
20
|
+
RelationshipRepeater,
|
|
21
|
+
Repeater,
|
|
22
|
+
RichEditor,
|
|
23
|
+
Select,
|
|
24
|
+
Slider,
|
|
25
|
+
TableSelect,
|
|
26
|
+
TagsInput,
|
|
27
|
+
Textarea,
|
|
28
|
+
TextInput,
|
|
29
|
+
TimePicker,
|
|
30
|
+
Toggle,
|
|
31
|
+
ToggleButtons,
|
|
32
|
+
ViewField,
|
|
33
|
+
)
|
|
34
|
+
from almasix.orbit.forms.form import Form
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Form",
|
|
38
|
+
"Field",
|
|
39
|
+
"TextInput",
|
|
40
|
+
"Textarea",
|
|
41
|
+
"Select",
|
|
42
|
+
"Checkbox",
|
|
43
|
+
"Toggle",
|
|
44
|
+
"DatePicker",
|
|
45
|
+
"DateTimePicker",
|
|
46
|
+
"TimePicker",
|
|
47
|
+
"FileUpload",
|
|
48
|
+
"Hidden",
|
|
49
|
+
"Placeholder",
|
|
50
|
+
"Radio",
|
|
51
|
+
"CheckboxList",
|
|
52
|
+
"TagsInput",
|
|
53
|
+
"ColorPicker",
|
|
54
|
+
"RichEditor",
|
|
55
|
+
"MarkdownEditor",
|
|
56
|
+
"KeyValue",
|
|
57
|
+
"Repeater",
|
|
58
|
+
"Builder",
|
|
59
|
+
"Slider",
|
|
60
|
+
"ToggleButtons",
|
|
61
|
+
"CodeEditor",
|
|
62
|
+
"MultiSelect",
|
|
63
|
+
"OneTimeCodeInput",
|
|
64
|
+
"ViewField",
|
|
65
|
+
"MorphToSelect",
|
|
66
|
+
"TableSelect",
|
|
67
|
+
"ModalTableSelect",
|
|
68
|
+
"RelationshipRepeater",
|
|
69
|
+
]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
|
|
2
|
+
"""Form fields — Filament-familiar fluent API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
from almasix.orbit.support.component import Component
|
|
10
|
+
from almasix.orbit.support.html import e
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Field(Component):
|
|
14
|
+
"""Base form field."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, name: str | None = None) -> None:
|
|
17
|
+
super().__init__(name)
|
|
18
|
+
self._rules: list[str | Callable[..., Any]] = []
|
|
19
|
+
self._required = False
|
|
20
|
+
self._placeholder: str | None = None
|
|
21
|
+
self._autocomplete: str | None = None
|
|
22
|
+
self._options: dict[Any, Any] | Callable[..., dict[Any, Any]] = {}
|
|
23
|
+
self._multiple = False
|
|
24
|
+
self._searchable = False
|
|
25
|
+
self._relationship: tuple[str, str] | None = None
|
|
26
|
+
self._input_type = "text"
|
|
27
|
+
self._rows: int | None = None
|
|
28
|
+
self._accepted_file_types: list[str] = []
|
|
29
|
+
self._max_size: int | None = None
|
|
30
|
+
|
|
31
|
+
def rules(self, *rules: str | Callable[..., Any]) -> Self:
|
|
32
|
+
self._rules.extend(rules)
|
|
33
|
+
return self
|
|
34
|
+
|
|
35
|
+
def get_rules(self) -> list[str | Callable[..., Any]]:
|
|
36
|
+
rules = list(self._rules)
|
|
37
|
+
if self._required and "required" not in rules:
|
|
38
|
+
rules.insert(0, "required")
|
|
39
|
+
return rules
|
|
40
|
+
|
|
41
|
+
def required(self, condition: bool = True) -> Self:
|
|
42
|
+
self._required = condition
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
def is_required(self) -> bool:
|
|
46
|
+
return self._required
|
|
47
|
+
|
|
48
|
+
def placeholder(self, text: str) -> Self:
|
|
49
|
+
self._placeholder = text
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def autocomplete(self, value: str) -> Self:
|
|
53
|
+
self._autocomplete = value
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def options(self, options: dict[Any, Any] | Callable[..., dict[Any, Any]]) -> Self:
|
|
57
|
+
self._options = options
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def get_options(self, **ctx: Any) -> dict[Any, Any]:
|
|
61
|
+
opts = self._options
|
|
62
|
+
return dict(opts(**ctx) if callable(opts) else opts)
|
|
63
|
+
|
|
64
|
+
def multiple(self, condition: bool = True) -> Self:
|
|
65
|
+
self._multiple = condition
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def searchable(self, condition: bool = True) -> Self:
|
|
69
|
+
self._searchable = condition
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def relationship(self, name: str, title_attribute: str) -> Self:
|
|
73
|
+
self._relationship = (name, title_attribute)
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def email(self) -> Self:
|
|
77
|
+
self._input_type = "email"
|
|
78
|
+
return self.rules("email")
|
|
79
|
+
|
|
80
|
+
def password(self) -> Self:
|
|
81
|
+
self._input_type = "password"
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def numeric(self) -> Self:
|
|
85
|
+
self._input_type = "number"
|
|
86
|
+
return self.rules("numeric")
|
|
87
|
+
|
|
88
|
+
def tel(self) -> Self:
|
|
89
|
+
self._input_type = "tel"
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def url(self) -> Self:
|
|
93
|
+
self._input_type = "url"
|
|
94
|
+
return self.rules("url")
|
|
95
|
+
|
|
96
|
+
def integer(self) -> Self:
|
|
97
|
+
return self.numeric().rules("integer")
|
|
98
|
+
|
|
99
|
+
def max_length(self, length: int) -> Self:
|
|
100
|
+
return self.rules(f"max:{length}")
|
|
101
|
+
|
|
102
|
+
def min_length(self, length: int) -> Self:
|
|
103
|
+
return self.rules(f"min:{length}")
|
|
104
|
+
|
|
105
|
+
def rows(self, count: int) -> Self:
|
|
106
|
+
self._rows = count
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def accepted_file_types(self, types: Sequence[str]) -> Self:
|
|
110
|
+
self._accepted_file_types = list(types)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def max_size(self, kilobytes: int) -> Self:
|
|
114
|
+
self._max_size = kilobytes
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict[str, Any]:
|
|
118
|
+
d = super().to_dict()
|
|
119
|
+
d.update({
|
|
120
|
+
"required": self._required,
|
|
121
|
+
"rules": [r if isinstance(r, str) else getattr(r, "__name__", "callable") for r in self._rules],
|
|
122
|
+
"placeholder": self._placeholder,
|
|
123
|
+
"input_type": self._input_type,
|
|
124
|
+
"multiple": self._multiple,
|
|
125
|
+
"searchable": self._searchable,
|
|
126
|
+
"relationship": self._relationship,
|
|
127
|
+
"options": self.get_options() if not callable(self._options) else {},
|
|
128
|
+
})
|
|
129
|
+
return d
|
|
130
|
+
|
|
131
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
132
|
+
if not self.is_visible(**ctx):
|
|
133
|
+
return ""
|
|
134
|
+
name = e(self.get_state_path() or "")
|
|
135
|
+
label = e(self.get_label())
|
|
136
|
+
req = ' <span class="or-required">*</span>' if self._required else ""
|
|
137
|
+
ph = f' placeholder="{e(self._placeholder)}"' if self._placeholder else ""
|
|
138
|
+
disabled = " disabled" if self.is_disabled(**ctx) else ""
|
|
139
|
+
val = "" if state is None else e(str(state))
|
|
140
|
+
live = ' wire:model.live' if self._live else ' wire:model'
|
|
141
|
+
helper = f'<p class="or-helper">{e(self._helper_text)}</p>' if self._helper_text else ""
|
|
142
|
+
return (
|
|
143
|
+
f'<div class="or-field or-field-{type(self).__name__}" data-field="{name}">'
|
|
144
|
+
f'<label class="or-label" for="or-{name}">{label}{req}</label>'
|
|
145
|
+
f'<input class="or-input" id="or-{name}" name="{name}" type="{e(self._input_type)}" '
|
|
146
|
+
f'value="{val}"{ph}{disabled}{live}="{name}" />{helper}</div>'
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TextInput(Field):
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Textarea(Field):
|
|
155
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
156
|
+
if not self.is_visible(**ctx):
|
|
157
|
+
return ""
|
|
158
|
+
name = e(self.get_state_path() or "")
|
|
159
|
+
label = e(self.get_label())
|
|
160
|
+
rows = self._rows or 4
|
|
161
|
+
val = "" if state is None else e(str(state))
|
|
162
|
+
disabled = " disabled" if self.is_disabled(**ctx) else ""
|
|
163
|
+
live = ' wire:model.live' if self._live else ' wire:model'
|
|
164
|
+
return (
|
|
165
|
+
f'<div class="or-field or-field-Textarea" data-field="{name}">'
|
|
166
|
+
f'<label class="or-label" for="or-{name}">{label}</label>'
|
|
167
|
+
f'<textarea class="or-textarea" id="or-{name}" name="{name}" rows="{rows}"'
|
|
168
|
+
f'{disabled}{live}="{name}">{val}</textarea></div>'
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Select(Field):
|
|
173
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
174
|
+
if not self.is_visible(**ctx):
|
|
175
|
+
return ""
|
|
176
|
+
name = e(self.get_state_path() or "")
|
|
177
|
+
label = e(self.get_label())
|
|
178
|
+
opts = []
|
|
179
|
+
for k, v in self.get_options(**ctx).items():
|
|
180
|
+
sel = " selected" if str(k) == str(state) else ""
|
|
181
|
+
opts.append(f'<option value="{e(k)}"{sel}>{e(v)}</option>')
|
|
182
|
+
multi = " multiple" if self._multiple else ""
|
|
183
|
+
disabled = " disabled" if self.is_disabled(**ctx) else ""
|
|
184
|
+
live = ' wire:model.live' if self._live else ' wire:model'
|
|
185
|
+
return (
|
|
186
|
+
f'<div class="or-field or-field-Select" data-field="{name}">'
|
|
187
|
+
f'<label class="or-label" for="or-{name}">{label}</label>'
|
|
188
|
+
f'<select class="or-select" id="or-{name}" name="{name}"{multi}{disabled}'
|
|
189
|
+
f'{live}="{name}">{"".join(opts)}</select></div>'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Checkbox(Field):
|
|
194
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
195
|
+
if not self.is_visible(**ctx):
|
|
196
|
+
return ""
|
|
197
|
+
name = e(self.get_state_path() or "")
|
|
198
|
+
label = e(self.get_label())
|
|
199
|
+
checked = " checked" if state else ""
|
|
200
|
+
disabled = " disabled" if self.is_disabled(**ctx) else ""
|
|
201
|
+
return (
|
|
202
|
+
f'<div class="or-field or-field-Checkbox" data-field="{name}">'
|
|
203
|
+
f'<label class="or-checkbox-label"><input class="or-checkbox" type="checkbox" '
|
|
204
|
+
f'name="{name}" wire:model="{name}"{checked}{disabled} /> {label}</label></div>'
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Toggle(Checkbox):
|
|
209
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
210
|
+
html = super().render(state, **ctx)
|
|
211
|
+
return html.replace("or-field-Checkbox", "or-field-Toggle").replace("or-checkbox", "or-toggle")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Hidden(Field):
|
|
215
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
216
|
+
name = e(self.get_state_path() or "")
|
|
217
|
+
val = "" if state is None else e(str(state))
|
|
218
|
+
return f'<input type="hidden" name="{name}" value="{val}" wire:model="{name}" />'
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Placeholder(Field):
|
|
222
|
+
def __init__(self, name: str | None = None) -> None:
|
|
223
|
+
super().__init__(name)
|
|
224
|
+
self._content: str = ""
|
|
225
|
+
self._dehydrated = False
|
|
226
|
+
|
|
227
|
+
def content(self, text: str) -> Self:
|
|
228
|
+
self._content = text
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
232
|
+
return f'<div class="or-placeholder"><p class="or-placeholder-content">{e(self._content or state or "")}</p></div>'
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class DatePicker(Field):
|
|
236
|
+
def __init__(self, name: str | None = None) -> None:
|
|
237
|
+
super().__init__(name)
|
|
238
|
+
self._input_type = "date"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class DateTimePicker(Field):
|
|
242
|
+
def __init__(self, name: str | None = None) -> None:
|
|
243
|
+
super().__init__(name)
|
|
244
|
+
self._input_type = "datetime-local"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class TimePicker(Field):
|
|
248
|
+
def __init__(self, name: str | None = None) -> None:
|
|
249
|
+
super().__init__(name)
|
|
250
|
+
self._input_type = "time"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class FileUpload(Field):
|
|
254
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
255
|
+
if not self.is_visible(**ctx):
|
|
256
|
+
return ""
|
|
257
|
+
name = e(self.get_state_path() or "")
|
|
258
|
+
label = e(self.get_label())
|
|
259
|
+
accept = ",".join(self._accepted_file_types)
|
|
260
|
+
acc = f' accept="{e(accept)}"' if accept else ""
|
|
261
|
+
return (
|
|
262
|
+
f'<div class="or-field or-field-FileUpload" data-field="{name}">'
|
|
263
|
+
f'<label class="or-label" for="or-{name}">{label}</label>'
|
|
264
|
+
f'<input class="or-file" id="or-{name}" type="file" name="{name}"{acc} '
|
|
265
|
+
f'wire:model="{name}" /></div>'
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class Radio(Select):
|
|
270
|
+
def render(self, state: Any = None, **ctx: Any) -> str:
|
|
271
|
+
if not self.is_visible(**ctx):
|
|
272
|
+
return ""
|
|
273
|
+
name = e(self.get_state_path() or "")
|
|
274
|
+
label = e(self.get_label())
|
|
275
|
+
opts = []
|
|
276
|
+
for k, v in self.get_options(**ctx).items():
|
|
277
|
+
checked = " checked" if str(k) == str(state) else ""
|
|
278
|
+
opts.append(
|
|
279
|
+
f'<label class="or-radio-label"><input type="radio" class="or-radio" '
|
|
280
|
+
f'name="{name}" value="{e(k)}" wire:model="{name}"{checked} /> {e(v)}</label>'
|
|
281
|
+
)
|
|
282
|
+
return (
|
|
283
|
+
f'<div class="or-field or-field-Radio" data-field="{name}" role="radiogroup" '
|
|
284
|
+
f'aria-label="{label}"><span class="or-label">{label}</span>{"".join(opts)}</div>'
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class CheckboxList(Select):
|
|
289
|
+
def __init__(self, name: str | None = None) -> None:
|
|
290
|
+
super().__init__(name)
|
|
291
|
+
self._multiple = True
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class TagsInput(Field):
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class ColorPicker(Field):
|
|
299
|
+
def __init__(self, name: str | None = None) -> None:
|
|
300
|
+
super().__init__(name)
|
|
301
|
+
self._input_type = "color"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class RichEditor(Textarea):
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class MarkdownEditor(Textarea):
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class KeyValue(Field):
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class Repeater(Field):
|
|
317
|
+
def __init__(self, name: str | None = None) -> None:
|
|
318
|
+
super().__init__(name)
|
|
319
|
+
self._schema: list[Component] = []
|
|
320
|
+
|
|
321
|
+
def schema(self, components: Sequence[Component]) -> Self:
|
|
322
|
+
self._schema = list(components)
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
def get_schema(self) -> list[Component]:
|
|
326
|
+
return list(self._schema)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Builder(Repeater):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class Slider(Field):
|
|
334
|
+
def __init__(self, name: str | None = None) -> None:
|
|
335
|
+
super().__init__(name)
|
|
336
|
+
self._input_type = "range"
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ToggleButtons(Select):
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class CodeEditor(Textarea):
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class MultiSelect(Select):
|
|
348
|
+
def __init__(self, name: str | None = None) -> None:
|
|
349
|
+
super().__init__(name)
|
|
350
|
+
self._multiple = True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class OneTimeCodeInput(TextInput):
|
|
354
|
+
def __init__(self, name: str | None = None) -> None:
|
|
355
|
+
super().__init__(name)
|
|
356
|
+
self._input_type = "text"
|
|
357
|
+
self._autocomplete = "one-time-code"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class ViewField(Field):
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class MorphToSelect(Select):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class TableSelect(Select):
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class ModalTableSelect(Select):
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class RelationshipRepeater(Repeater):
|
|
377
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
|
|
2
|
+
"""Form builder wrapping Schema."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
from almasix.orbit.schemas.schema import Schema
|
|
10
|
+
from almasix.orbit.support.component import Component
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Form(Schema):
|
|
14
|
+
"""Filament-style Form configuration object."""
|
|
15
|
+
|
|
16
|
+
def schema(self, components: Sequence[Component]) -> Self: # type: ignore[override]
|
|
17
|
+
return self.components(components)
|
|
18
|
+
|
|
19
|
+
def validate(self, data: dict[str, Any] | None = None) -> dict[str, list[str]]:
|
|
20
|
+
"""Return field → error messages using simple rule strings."""
|
|
21
|
+
state = data if data is not None else self.get_state()
|
|
22
|
+
errors: dict[str, list[str]] = {}
|
|
23
|
+
for c in self.get_components():
|
|
24
|
+
from almasix.orbit.forms.components import Field
|
|
25
|
+
|
|
26
|
+
if not isinstance(c, Field):
|
|
27
|
+
continue
|
|
28
|
+
path = c.get_state_path() or ""
|
|
29
|
+
value = state.get(path)
|
|
30
|
+
for rule in c.get_rules():
|
|
31
|
+
if not isinstance(rule, str):
|
|
32
|
+
continue
|
|
33
|
+
msg = _check_rule(rule, value, path)
|
|
34
|
+
if msg:
|
|
35
|
+
errors.setdefault(path, []).append(msg)
|
|
36
|
+
return errors
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_rule(rule: str, value: Any, path: str) -> str | None:
|
|
40
|
+
if rule == "required":
|
|
41
|
+
if value is None or value == "" or value == []:
|
|
42
|
+
return f"The {path} field is required."
|
|
43
|
+
return None
|
|
44
|
+
if rule == "email":
|
|
45
|
+
if value and "@" not in str(value):
|
|
46
|
+
return f"The {path} must be a valid email address."
|
|
47
|
+
return None
|
|
48
|
+
if rule == "numeric":
|
|
49
|
+
if value not in (None, "") and not _is_number(value):
|
|
50
|
+
return f"The {path} must be numeric."
|
|
51
|
+
return None
|
|
52
|
+
if rule == "integer":
|
|
53
|
+
if value not in (None, "") and not str(value).lstrip("-").isdigit():
|
|
54
|
+
return f"The {path} must be an integer."
|
|
55
|
+
return None
|
|
56
|
+
if rule == "url":
|
|
57
|
+
if value and not str(value).startswith(("http://", "https://")):
|
|
58
|
+
return f"The {path} must be a valid URL."
|
|
59
|
+
return None
|
|
60
|
+
if rule.startswith("max:"):
|
|
61
|
+
n = int(rule.split(":", 1)[1])
|
|
62
|
+
if value is not None and len(str(value)) > n:
|
|
63
|
+
return f"The {path} must not be greater than {n} characters."
|
|
64
|
+
return None
|
|
65
|
+
if rule.startswith("min:"):
|
|
66
|
+
n = int(rule.split(":", 1)[1])
|
|
67
|
+
if value is not None and len(str(value)) < n:
|
|
68
|
+
return f"The {path} must be at least {n} characters."
|
|
69
|
+
return None
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_number(value: Any) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
float(value)
|
|
76
|
+
return True
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return False
|