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.
@@ -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,3 @@
1
+ # almasix-orbit-forms
2
+
3
+ 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,2 @@
1
+ """Namespace package for Almasix."""
2
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,2 @@
1
+ """Orbit namespace."""
2
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -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