pulse-framework 0.1.62__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.
- pulse/__init__.py +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
pulse/form.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Never,
|
|
8
|
+
TypedDict,
|
|
9
|
+
Unpack,
|
|
10
|
+
override,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException, Request, Response
|
|
14
|
+
from starlette.datastructures import FormData as StarletteFormData
|
|
15
|
+
from starlette.datastructures import UploadFile
|
|
16
|
+
|
|
17
|
+
from pulse.context import PulseContext
|
|
18
|
+
from pulse.dom.props import HTMLFormProps
|
|
19
|
+
from pulse.helpers import Disposable, call_flexible, maybe_await
|
|
20
|
+
from pulse.hooks.core import HOOK_CONTEXT, HookMetadata, HookState, hooks
|
|
21
|
+
from pulse.hooks.runtime import server_address
|
|
22
|
+
from pulse.hooks.stable import stable
|
|
23
|
+
from pulse.react_component import react_component
|
|
24
|
+
from pulse.reactive import Signal
|
|
25
|
+
from pulse.serializer import deserialize
|
|
26
|
+
from pulse.transpiler.imports import Import
|
|
27
|
+
from pulse.transpiler.nodes import Node
|
|
28
|
+
from pulse.types.event_handler import EventHandler1
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pulse.render_session import RenderSession
|
|
32
|
+
from pulse.user_session import UserSession
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Form",
|
|
37
|
+
"ManualForm",
|
|
38
|
+
"FormData",
|
|
39
|
+
"FormValue",
|
|
40
|
+
"UploadFile",
|
|
41
|
+
"FormRegistry",
|
|
42
|
+
"FormStorage",
|
|
43
|
+
"internal_forms_hook",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
FormValue = str | UploadFile
|
|
47
|
+
"""Individual form field value: ``str | UploadFile``."""
|
|
48
|
+
|
|
49
|
+
FormData = dict[str, FormValue | list[FormValue]]
|
|
50
|
+
"""Parsed form submission data.
|
|
51
|
+
|
|
52
|
+
Values are either single or multiple (for repeated field names).
|
|
53
|
+
Type alias for ``dict[str, FormValue | list[FormValue]]``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@react_component(Import("PulseForm", "pulse-ui-client"))
|
|
58
|
+
def client_form_component(
|
|
59
|
+
*children: Node,
|
|
60
|
+
key: str | None = None,
|
|
61
|
+
**props: Unpack[HTMLFormProps],
|
|
62
|
+
): ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class FormRegistration:
|
|
67
|
+
"""Internal registration info for a form.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
id: Unique form identifier.
|
|
71
|
+
render_id: Associated render session ID.
|
|
72
|
+
route_path: Route path this form is bound to.
|
|
73
|
+
session_id: Associated user session ID.
|
|
74
|
+
on_submit: Async callback for form submission.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
id: str
|
|
78
|
+
render_id: str
|
|
79
|
+
route_path: str
|
|
80
|
+
session_id: str
|
|
81
|
+
on_submit: Callable[[FormData], Awaitable[None]]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class FormRegistry(Disposable):
|
|
85
|
+
"""Internal class managing form registrations.
|
|
86
|
+
|
|
87
|
+
Not typically used directly. Forms are registered automatically via
|
|
88
|
+
``ps.Form`` or ``ManualForm``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, render: "RenderSession") -> None:
|
|
92
|
+
self._render: "RenderSession" = render
|
|
93
|
+
self._handlers: dict[str, FormRegistration] = {}
|
|
94
|
+
|
|
95
|
+
def register(
|
|
96
|
+
self,
|
|
97
|
+
render_id: str,
|
|
98
|
+
route_id: str,
|
|
99
|
+
session_id: str,
|
|
100
|
+
on_submit: Callable[[FormData], Awaitable[None]],
|
|
101
|
+
) -> FormRegistration:
|
|
102
|
+
"""Register a form handler.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
render_id: Render session ID.
|
|
106
|
+
route_id: Route path.
|
|
107
|
+
session_id: User session ID.
|
|
108
|
+
on_submit: Async callback for form submission.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
FormRegistration with generated form ID.
|
|
112
|
+
"""
|
|
113
|
+
registration = FormRegistration(
|
|
114
|
+
uuid.uuid4().hex,
|
|
115
|
+
render_id=render_id,
|
|
116
|
+
route_path=route_id,
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
on_submit=on_submit,
|
|
119
|
+
)
|
|
120
|
+
self._handlers[registration.id] = registration
|
|
121
|
+
return registration
|
|
122
|
+
|
|
123
|
+
def unregister(self, form_id: str) -> None:
|
|
124
|
+
"""Unregister a form handler.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
form_id: The form ID to unregister.
|
|
128
|
+
"""
|
|
129
|
+
self._handlers.pop(form_id, None)
|
|
130
|
+
|
|
131
|
+
@override
|
|
132
|
+
def dispose(self) -> None:
|
|
133
|
+
"""Clean up all registered forms."""
|
|
134
|
+
self._handlers.clear()
|
|
135
|
+
|
|
136
|
+
async def handle_submit(
|
|
137
|
+
self,
|
|
138
|
+
form_id: str,
|
|
139
|
+
request: Request,
|
|
140
|
+
session: "UserSession",
|
|
141
|
+
) -> Response:
|
|
142
|
+
"""Handle incoming form submission.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
form_id: The form ID being submitted.
|
|
146
|
+
request: The HTTP request.
|
|
147
|
+
session: The user session.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
HTTP response (204 on success).
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
HTTPException: If form not found (404), session mismatch (403),
|
|
154
|
+
or route unmounted (410).
|
|
155
|
+
"""
|
|
156
|
+
registration = self._handlers.get(form_id)
|
|
157
|
+
if registration is None:
|
|
158
|
+
raise HTTPException(status_code=404, detail="Unknown form submission")
|
|
159
|
+
|
|
160
|
+
if registration.session_id != session.sid:
|
|
161
|
+
raise HTTPException(
|
|
162
|
+
status_code=403, detail="Form does not belong to this session"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
raw_form = await request.form()
|
|
166
|
+
data = normalize_form_data(raw_form)
|
|
167
|
+
|
|
168
|
+
# Deserialize complex data from __data__ field if present
|
|
169
|
+
if "__data__" in data:
|
|
170
|
+
data_value = data["__data__"]
|
|
171
|
+
if isinstance(data_value, str):
|
|
172
|
+
serialized_data = json.loads(data_value)
|
|
173
|
+
deserialized_data = deserialize(serialized_data)
|
|
174
|
+
# Merge deserialized data into form data, excluding __data__
|
|
175
|
+
for key, value in deserialized_data.items():
|
|
176
|
+
if key != "__data__":
|
|
177
|
+
data[key] = value
|
|
178
|
+
# Remove the __data__ field
|
|
179
|
+
del data["__data__"]
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
mount = self._render.get_route_mount(registration.route_path)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
self.unregister(form_id)
|
|
185
|
+
raise HTTPException(
|
|
186
|
+
status_code=410,
|
|
187
|
+
detail="Form route is no longer mounted",
|
|
188
|
+
) from exc
|
|
189
|
+
|
|
190
|
+
with PulseContext.update(render=self._render, route=mount.route):
|
|
191
|
+
await call_flexible(registration.on_submit, data)
|
|
192
|
+
|
|
193
|
+
return Response(status_code=204)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def normalize_form_data(raw: StarletteFormData) -> FormData:
|
|
197
|
+
"""Convert Starlette FormData to normalized FormData dict.
|
|
198
|
+
|
|
199
|
+
Handles multiple values for the same key and filters out empty file uploads.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
raw: Starlette FormData from request.form().
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Normalized FormData dictionary.
|
|
206
|
+
"""
|
|
207
|
+
normalized: FormData = {}
|
|
208
|
+
for key, value in raw.multi_items():
|
|
209
|
+
item: FormValue
|
|
210
|
+
if isinstance(value, UploadFile):
|
|
211
|
+
# Form submission tends to produce empty UploadFile objects for
|
|
212
|
+
# empty file inputs
|
|
213
|
+
if not value.filename and not value.size:
|
|
214
|
+
continue
|
|
215
|
+
item = value
|
|
216
|
+
else:
|
|
217
|
+
item = str(value)
|
|
218
|
+
|
|
219
|
+
existing = normalized.get(key)
|
|
220
|
+
if existing is None:
|
|
221
|
+
normalized[key] = item
|
|
222
|
+
elif isinstance(existing, list):
|
|
223
|
+
existing.append(item)
|
|
224
|
+
else:
|
|
225
|
+
normalized[key] = [existing, item]
|
|
226
|
+
|
|
227
|
+
return normalized
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class PulseFormProps(HTMLFormProps, total=False):
|
|
231
|
+
"""Form props that exclude action, method, encType, and onSubmit.
|
|
232
|
+
|
|
233
|
+
These props are auto-generated by Pulse for form handling.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
action: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
237
|
+
method: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
238
|
+
encType: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
239
|
+
onSubmit: Never # pyright: ignore[reportIncompatibleVariableOverride]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def Form(
|
|
243
|
+
*children: Node,
|
|
244
|
+
key: str,
|
|
245
|
+
onSubmit: EventHandler1[FormData] | None = None,
|
|
246
|
+
**props: Unpack[PulseFormProps], # pyright: ignore[reportGeneralTypeIssues]
|
|
247
|
+
) -> Node:
|
|
248
|
+
"""Server-registered HTML form component.
|
|
249
|
+
|
|
250
|
+
Automatically wires up form submission to a Python handler. Uses
|
|
251
|
+
``multipart/form-data`` encoding to support file uploads.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
*children: Form content (inputs, buttons, etc.).
|
|
255
|
+
key: Unique form identifier (required, non-empty string).
|
|
256
|
+
onSubmit: Submit handler receiving parsed FormData.
|
|
257
|
+
**props: Standard HTML form props (except action, method, encType, onSubmit).
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Form node.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
ValueError: If key is empty or onSubmit is not callable.
|
|
264
|
+
RuntimeError: If called outside a component render.
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
async def handle_submit(data: ps.FormData):
|
|
270
|
+
name = data.get("name") # str
|
|
271
|
+
file = data.get("avatar") # UploadFile
|
|
272
|
+
await save_user(name, file)
|
|
273
|
+
|
|
274
|
+
def my_form():
|
|
275
|
+
return ps.Form(
|
|
276
|
+
m.TextInput(name="name", label="Name"),
|
|
277
|
+
m.FileInput(name="avatar", label="Avatar"),
|
|
278
|
+
m.Button("Submit", type="submit"),
|
|
279
|
+
key="user-form",
|
|
280
|
+
onSubmit=handle_submit,
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Note:
|
|
285
|
+
- ``key`` must be unique within the render.
|
|
286
|
+
- Cannot override ``action``, ``method``, ``encType``, or ``onSubmit`` via props.
|
|
287
|
+
- Handler receives parsed form data as a ``FormData`` dict.
|
|
288
|
+
"""
|
|
289
|
+
if not isinstance(key, str) or not key:
|
|
290
|
+
raise ValueError("ps.Form requires a non-empty string key")
|
|
291
|
+
if not callable(onSubmit):
|
|
292
|
+
raise ValueError("ps.Form requires an onSubmit callable")
|
|
293
|
+
if "action" in props:
|
|
294
|
+
raise ValueError("ps.Form does not allow overriding the form action")
|
|
295
|
+
|
|
296
|
+
hook_state = HOOK_CONTEXT.get()
|
|
297
|
+
if hook_state is None:
|
|
298
|
+
raise RuntimeError("ps.Form can only be used within a component render")
|
|
299
|
+
|
|
300
|
+
handler = stable(f"form:{key}", onSubmit)
|
|
301
|
+
storage = internal_forms_hook()
|
|
302
|
+
manual = storage.register(
|
|
303
|
+
key,
|
|
304
|
+
lambda: ManualForm(handler),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return manual(*children, key=key, **props)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class GeneratedFormProps(TypedDict):
|
|
311
|
+
"""Form props generated by ``ManualForm.props()``.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
action: Form submission URL.
|
|
315
|
+
method: HTTP method ("POST").
|
|
316
|
+
encType: Encoding type ("multipart/form-data").
|
|
317
|
+
onSubmit: Submission trigger callback.
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
action: str
|
|
321
|
+
method: str
|
|
322
|
+
encType: str
|
|
323
|
+
onSubmit: Callable[[], None]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class ManualForm(Disposable):
|
|
327
|
+
"""Low-level form handler for custom form implementations.
|
|
328
|
+
|
|
329
|
+
Use when you need more control over form rendering than ``ps.Form`` provides.
|
|
330
|
+
|
|
331
|
+
Attributes:
|
|
332
|
+
is_submitting: Whether the form is currently submitting.
|
|
333
|
+
registration: Form registration info (raises if disposed).
|
|
334
|
+
|
|
335
|
+
Example:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
def custom_form():
|
|
339
|
+
manual = ManualForm(on_submit=handle_data)
|
|
340
|
+
|
|
341
|
+
# Option 1: Render directly
|
|
342
|
+
return manual(
|
|
343
|
+
m.TextInput(name="field"),
|
|
344
|
+
m.Button("Submit", type="submit"),
|
|
345
|
+
key="my-form",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Option 2: Use props manually
|
|
349
|
+
form_props = manual.props()
|
|
350
|
+
return m.form(
|
|
351
|
+
m.TextInput(name="field"),
|
|
352
|
+
m.Button("Submit", type="submit"),
|
|
353
|
+
**form_props,
|
|
354
|
+
)
|
|
355
|
+
```
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
_submit_signal: Signal[bool]
|
|
359
|
+
_render: "RenderSession"
|
|
360
|
+
_registration: FormRegistration | None
|
|
361
|
+
|
|
362
|
+
def __init__(self, on_submit: EventHandler1[FormData] | None = None) -> None:
|
|
363
|
+
"""Initialize a manual form handler.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
on_submit: Optional submit handler receiving parsed FormData.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
RuntimeError: If called outside a render pass, route context,
|
|
370
|
+
or user session.
|
|
371
|
+
"""
|
|
372
|
+
ctx = PulseContext.get()
|
|
373
|
+
render = ctx.render
|
|
374
|
+
route = ctx.route
|
|
375
|
+
session = ctx.session
|
|
376
|
+
if render is None:
|
|
377
|
+
raise RuntimeError("ManualForm must be created during a render pass")
|
|
378
|
+
if route is None:
|
|
379
|
+
raise RuntimeError("ManualForm requires an active route context")
|
|
380
|
+
if session is None:
|
|
381
|
+
raise RuntimeError("ManualForm requires an active user session")
|
|
382
|
+
|
|
383
|
+
self._submit_signal = Signal(False)
|
|
384
|
+
self._render = render
|
|
385
|
+
self._registration = render.forms.register(
|
|
386
|
+
session_id=session.sid,
|
|
387
|
+
render_id=render.id,
|
|
388
|
+
route_id=route.pulse_route.unique_path(),
|
|
389
|
+
on_submit=self.wrap_on_submit(on_submit),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def wrap_on_submit(self, on_submit: EventHandler1[FormData] | None):
|
|
393
|
+
async def on_submit_handler(data: FormData):
|
|
394
|
+
if on_submit:
|
|
395
|
+
await maybe_await(call_flexible(on_submit, data))
|
|
396
|
+
self._submit_signal.write(False)
|
|
397
|
+
|
|
398
|
+
return on_submit_handler
|
|
399
|
+
|
|
400
|
+
@property
|
|
401
|
+
def is_submitting(self) -> bool:
|
|
402
|
+
"""Whether the form is currently submitting."""
|
|
403
|
+
return self._submit_signal.read()
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def registration(self) -> FormRegistration:
|
|
407
|
+
"""Form registration info.
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
ValueError: If the form has been disposed.
|
|
411
|
+
"""
|
|
412
|
+
if self._registration is None:
|
|
413
|
+
raise ValueError("This form has been disposed")
|
|
414
|
+
return self._registration
|
|
415
|
+
|
|
416
|
+
def _start_submit(self) -> None:
|
|
417
|
+
self._submit_signal.write(True)
|
|
418
|
+
|
|
419
|
+
def props(self) -> GeneratedFormProps:
|
|
420
|
+
"""Get form props for manual binding to a form element.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Dict with action, method, encType, and onSubmit props.
|
|
424
|
+
"""
|
|
425
|
+
prefix = PulseContext.get().app.api_prefix
|
|
426
|
+
return {
|
|
427
|
+
"action": f"{server_address()}{prefix}/forms/{self._render.id}/{self.registration.id}",
|
|
428
|
+
"method": "POST",
|
|
429
|
+
"encType": "multipart/form-data",
|
|
430
|
+
"onSubmit": self._start_submit,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
def __call__(
|
|
434
|
+
self,
|
|
435
|
+
*children: Node,
|
|
436
|
+
key: str | None = None,
|
|
437
|
+
**props: Unpack[PulseFormProps],
|
|
438
|
+
) -> Node:
|
|
439
|
+
"""Render as a form element with children.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
*children: Form content.
|
|
443
|
+
key: Optional element key.
|
|
444
|
+
**props: Additional form props.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Form node with auto-generated submission props.
|
|
448
|
+
"""
|
|
449
|
+
props.update(self.props()) # pyright: ignore[reportCallIssue, reportArgumentType]
|
|
450
|
+
return client_form_component(*children, key=key, **props)
|
|
451
|
+
|
|
452
|
+
@override
|
|
453
|
+
def dispose(self) -> None:
|
|
454
|
+
"""Unregister the form and clean up."""
|
|
455
|
+
if self._registration is None:
|
|
456
|
+
return
|
|
457
|
+
self._render.forms.unregister(self._registration.id)
|
|
458
|
+
self._registration = None
|
|
459
|
+
|
|
460
|
+
def update(self, on_submit: EventHandler1[FormData] | None) -> None:
|
|
461
|
+
"""Update the submit handler.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
on_submit: New submit handler.
|
|
465
|
+
"""
|
|
466
|
+
self.registration.on_submit = self.wrap_on_submit(on_submit)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class FormStorage(HookState):
|
|
470
|
+
"""Internal hook state for managing form lifecycle within renders.
|
|
471
|
+
|
|
472
|
+
Not typically used directly. Manages form persistence and cleanup across
|
|
473
|
+
render cycles.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
__slots__ = ("forms", "prev_forms", "render_mark") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
477
|
+
render_mark: int
|
|
478
|
+
|
|
479
|
+
def __init__(self) -> None:
|
|
480
|
+
super().__init__()
|
|
481
|
+
self.forms: dict[str, ManualForm] = {}
|
|
482
|
+
self.prev_forms: dict[str, ManualForm] = {}
|
|
483
|
+
self.render_mark = 0
|
|
484
|
+
|
|
485
|
+
@override
|
|
486
|
+
def on_render_start(self, render_cycle: int) -> None:
|
|
487
|
+
super().on_render_start(render_cycle)
|
|
488
|
+
if self.render_mark == render_cycle:
|
|
489
|
+
return
|
|
490
|
+
self.prev_forms = self.forms
|
|
491
|
+
self.forms = {}
|
|
492
|
+
self.render_mark = render_cycle
|
|
493
|
+
|
|
494
|
+
@override
|
|
495
|
+
def on_render_end(self, render_cycle: int) -> None:
|
|
496
|
+
if not self.prev_forms:
|
|
497
|
+
return
|
|
498
|
+
for form in self.prev_forms.values():
|
|
499
|
+
form.dispose()
|
|
500
|
+
self.prev_forms.clear()
|
|
501
|
+
|
|
502
|
+
def register(
|
|
503
|
+
self,
|
|
504
|
+
key: str,
|
|
505
|
+
factory: Callable[[], ManualForm],
|
|
506
|
+
) -> ManualForm:
|
|
507
|
+
if key in self.forms:
|
|
508
|
+
raise RuntimeError(
|
|
509
|
+
f"Duplicate ps.Form id '{key}' detected within the same render"
|
|
510
|
+
)
|
|
511
|
+
form = self.prev_forms.pop(key, None)
|
|
512
|
+
if form is None:
|
|
513
|
+
form = factory()
|
|
514
|
+
self.forms[key] = form
|
|
515
|
+
return form
|
|
516
|
+
|
|
517
|
+
@override
|
|
518
|
+
def dispose(self) -> None:
|
|
519
|
+
for form in self.forms.values():
|
|
520
|
+
form.dispose()
|
|
521
|
+
for form in self.prev_forms.values():
|
|
522
|
+
form.dispose()
|
|
523
|
+
self.forms.clear()
|
|
524
|
+
self.prev_forms.clear()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _forms_factory():
|
|
528
|
+
return FormStorage()
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
internal_forms_hook = hooks.create(
|
|
532
|
+
"pulse:core.forms",
|
|
533
|
+
_forms_factory,
|
|
534
|
+
metadata=HookMetadata(
|
|
535
|
+
owner="pulse.core",
|
|
536
|
+
description="Internal storage for ps.Form manual forms",
|
|
537
|
+
),
|
|
538
|
+
)
|