millwork 0.1.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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Feedback
|
|
3
|
+
order: 3
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Feedback
|
|
7
|
+
|
|
8
|
+
Telling the user what is going on: alerts, toasts, dialogs, progress and the
|
|
9
|
+
empty and loading states.
|
|
10
|
+
|
|
11
|
+
## Alert
|
|
12
|
+
|
|
13
|
+
`ui.Alert` is a banner with a `title`, a `description`, a `tone`, an `icon`,
|
|
14
|
+
`actions` and, with `dismissable`, a close button that fires `on_dismiss`.
|
|
15
|
+
|
|
16
|
+
```python live
|
|
17
|
+
ui.Stack(
|
|
18
|
+
ui.Alert(title="Heads up", description="Something to know, with the default tone."),
|
|
19
|
+
ui.Alert(title="Saved", description="The project was saved.", tone="success"),
|
|
20
|
+
ui.Alert(title="Check the dates", description="The end is before the start.", tone="warning"),
|
|
21
|
+
ui.Alert(
|
|
22
|
+
title="Upload failed",
|
|
23
|
+
description="The file was larger than 50 MB.",
|
|
24
|
+
tone="destructive",
|
|
25
|
+
actions=[ui.Button("Try again", size="sm", on_click=lambda s, _: s.toast("Retrying"))],
|
|
26
|
+
),
|
|
27
|
+
ui.Alert(title="Dismiss me", dismissable=True, on_dismiss=lambda s, _: s.toast("Dismissed")),
|
|
28
|
+
gap=3,
|
|
29
|
+
)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Toast
|
|
33
|
+
|
|
34
|
+
A toast is not a component but a command: `s.toast(message, tone=, title=)`
|
|
35
|
+
from any handler. `action=(label, handler)` puts a button on it that runs
|
|
36
|
+
the handler once.
|
|
37
|
+
|
|
38
|
+
```python live
|
|
39
|
+
ui.Row(
|
|
40
|
+
ui.Button("Plain", variant="outline", on_click=lambda s, _: s.toast("A plain toast.")),
|
|
41
|
+
ui.Button(
|
|
42
|
+
"Success", variant="outline", on_click=lambda s, _: s.toast("Saved.", tone="success")
|
|
43
|
+
),
|
|
44
|
+
ui.Button(
|
|
45
|
+
"With title",
|
|
46
|
+
variant="outline",
|
|
47
|
+
on_click=lambda s, _: s.toast("Three rows changed.", title="Imported"),
|
|
48
|
+
),
|
|
49
|
+
ui.Button(
|
|
50
|
+
"With action",
|
|
51
|
+
variant="outline",
|
|
52
|
+
on_click=lambda s, _: s.toast(
|
|
53
|
+
"Deleted.", tone="destructive", action=("Undo", lambda s, _: s.toast("Restored."))
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
gap=2,
|
|
57
|
+
wrap=True,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Dialog
|
|
62
|
+
|
|
63
|
+
`ui.Dialog` is open when state says so. It has a `title`, a `description`, a
|
|
64
|
+
`footer`, a `size` from `sm` to `full`, and `on_close` fires when the user
|
|
65
|
+
dismisses it, so the handler sets state and it closes.
|
|
66
|
+
|
|
67
|
+
```python live
|
|
68
|
+
from pydantic import BaseModel
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Modal(BaseModel):
|
|
72
|
+
open: bool = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def example(s):
|
|
76
|
+
st = state(Modal)
|
|
77
|
+
|
|
78
|
+
def close(s, _):
|
|
79
|
+
st.open = False
|
|
80
|
+
|
|
81
|
+
return ui.Row(
|
|
82
|
+
ui.Button("Open a dialog", on_click=lambda s, _: setattr(st, "open", True)),
|
|
83
|
+
ui.Dialog(
|
|
84
|
+
ui.Text("Dialogs hold any children. This one has a footer with a button."),
|
|
85
|
+
open=st.open,
|
|
86
|
+
title="A dialog",
|
|
87
|
+
description="Close it with the button, the cross, Escape or a click outside.",
|
|
88
|
+
footer=ui.Button("Done", on_click=close),
|
|
89
|
+
on_close=close,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Confirm and ask
|
|
95
|
+
|
|
96
|
+
Two session calls wait for the user from inside a handler. `await s.confirm(
|
|
97
|
+
title, description=, destructive=)` shows a dialog and returns True or
|
|
98
|
+
False. `await s.ask(component)` shows any component and returns what it
|
|
99
|
+
submits: a `Form` answers with its validated model, a closed dialog answers
|
|
100
|
+
None.
|
|
101
|
+
|
|
102
|
+
```python live
|
|
103
|
+
from pydantic import BaseModel
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Asked(BaseModel):
|
|
107
|
+
answer: str = "nothing yet"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def confirm(s, _):
|
|
111
|
+
st = state(Asked)
|
|
112
|
+
ok = await s.confirm("Archive the project?", "You can restore it later.", destructive=True)
|
|
113
|
+
st.answer = "archived" if ok else "kept"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def example(s):
|
|
117
|
+
st = state(Asked)
|
|
118
|
+
return ui.Row(
|
|
119
|
+
ui.Button("Archive", variant="outline", on_click=confirm),
|
|
120
|
+
ui.Text(f"Result: {st.answer}", variant="muted"),
|
|
121
|
+
gap=3,
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Sheet
|
|
126
|
+
|
|
127
|
+
`ui.Sheet` slides a panel in from a `side`. Like a dialog it is open when
|
|
128
|
+
state says so and `on_close` lets the handler close it.
|
|
129
|
+
|
|
130
|
+
```python live
|
|
131
|
+
from pydantic import BaseModel
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Panel(BaseModel):
|
|
135
|
+
open: bool = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def example(s):
|
|
139
|
+
st = state(Panel)
|
|
140
|
+
return ui.Row(
|
|
141
|
+
ui.Button(
|
|
142
|
+
"Open settings", variant="outline", on_click=lambda s, _: setattr(st, "open", True)
|
|
143
|
+
),
|
|
144
|
+
ui.Sheet(
|
|
145
|
+
ui.Switch(label="Email summaries", default_checked=True),
|
|
146
|
+
ui.Select(options=["Fast", "Thorough"], default="Fast"),
|
|
147
|
+
open=st.open,
|
|
148
|
+
side="right",
|
|
149
|
+
title="Settings",
|
|
150
|
+
description="A sheet holding a few controls.",
|
|
151
|
+
on_close=lambda s, _: setattr(st, "open", False),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Spinner and Skeleton
|
|
157
|
+
|
|
158
|
+
`ui.Spinner` shows work in progress with an optional `label`. `ui.Skeleton`
|
|
159
|
+
stands in for content still loading, shaped like `text`, a `card` or a
|
|
160
|
+
`table`.
|
|
161
|
+
|
|
162
|
+
```python live
|
|
163
|
+
ui.Stack(
|
|
164
|
+
ui.Row(
|
|
165
|
+
ui.Spinner(size="sm"),
|
|
166
|
+
ui.Spinner(),
|
|
167
|
+
ui.Spinner(size="lg", label="Loading the project"),
|
|
168
|
+
gap=6,
|
|
169
|
+
),
|
|
170
|
+
ui.Grid(
|
|
171
|
+
ui.Skeleton(rows=3),
|
|
172
|
+
ui.Skeleton(shape="card"),
|
|
173
|
+
ui.Skeleton(shape="table", rows=4),
|
|
174
|
+
cols={"sm": 1, "md": 3},
|
|
175
|
+
),
|
|
176
|
+
gap=6,
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Empty
|
|
181
|
+
|
|
182
|
+
`ui.Empty` is the state before there is anything: a `title`, a
|
|
183
|
+
`description`, an `icon` and an `action`.
|
|
184
|
+
|
|
185
|
+
```python live
|
|
186
|
+
ui.Empty(
|
|
187
|
+
title="No projects yet",
|
|
188
|
+
description="Create one to start, or import from a file.",
|
|
189
|
+
icon="inbox",
|
|
190
|
+
action=ui.Button("New project", on_click=lambda s, _: s.toast("Creating")),
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Progress and Steps
|
|
195
|
+
|
|
196
|
+
`ui.Progress` is a bar for a `value` from 0 to 1 with a `label` and a
|
|
197
|
+
`description`, or for a running task (see the Tasks chapter). `ui.Steps`
|
|
198
|
+
draws a list of `mw.Step` items with their status.
|
|
199
|
+
|
|
200
|
+
```python live
|
|
201
|
+
ui.Stack(
|
|
202
|
+
ui.Progress(value=0.65, label="Importing", description="130 of 200 rows"),
|
|
203
|
+
ui.Steps(
|
|
204
|
+
items=[
|
|
205
|
+
mw.Step(id="read", label="Read the document", status="done", note="12 pages"),
|
|
206
|
+
mw.Step(id="find", label="Find the headings", status="running"),
|
|
207
|
+
mw.Step(id="route", label="Add routing", status="pending"),
|
|
208
|
+
]
|
|
209
|
+
),
|
|
210
|
+
gap=6,
|
|
211
|
+
)
|
|
212
|
+
```
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Files
|
|
3
|
+
order: 7
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Files
|
|
7
|
+
|
|
8
|
+
Files belong to the session. An upload becomes a `mw.FileRef` with an id, a
|
|
9
|
+
name, a size and a type; the bytes live on the server for as long as the
|
|
10
|
+
session does and travel to the browser over the session's own socket, so a
|
|
11
|
+
file has no URL and needs no signing.
|
|
12
|
+
|
|
13
|
+
## FileUpload
|
|
14
|
+
|
|
15
|
+
`ui.FileUpload` is a drop zone (`dropzone=False` makes it a button). `accept`
|
|
16
|
+
lists extensions, `multiple` and `max_files` allow more than one,
|
|
17
|
+
`max_size_mb` caps each. `on_upload` receives the `FileRef`s; `on_reject`
|
|
18
|
+
receives `{name, reason}` for a file that was refused.
|
|
19
|
+
|
|
20
|
+
```python live
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Uploads(BaseModel):
|
|
25
|
+
files: list = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def uploaded(s, refs):
|
|
29
|
+
state(Uploads).files.extend(refs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def example(s):
|
|
33
|
+
st = state(Uploads)
|
|
34
|
+
return ui.Stack(
|
|
35
|
+
ui.FileUpload(
|
|
36
|
+
accept=[".txt", ".md", ".png", ".pdf"],
|
|
37
|
+
multiple=True,
|
|
38
|
+
max_files=3,
|
|
39
|
+
max_size_mb=5,
|
|
40
|
+
on_upload=uploaded,
|
|
41
|
+
on_reject=lambda s, r: s.toast(f"{r['name']}: {r['reason']}", tone="warning"),
|
|
42
|
+
),
|
|
43
|
+
*(ui.FileCard(file=f, key=f.id) for f in st.files),
|
|
44
|
+
gap=3,
|
|
45
|
+
)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## FileCard
|
|
49
|
+
|
|
50
|
+
`ui.FileCard` shows a file with its size and kind, an optional `meta` line,
|
|
51
|
+
a `status` and `actions`. Given a `FileRef`, Open shows the bytes in a new
|
|
52
|
+
tab; given a `url`, it links there.
|
|
53
|
+
|
|
54
|
+
```python live
|
|
55
|
+
from pydantic import BaseModel
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Held(BaseModel):
|
|
59
|
+
ref: object = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def example(s):
|
|
63
|
+
st = state(Held)
|
|
64
|
+
if st.ref is None:
|
|
65
|
+
st.ref = s.files.add("Notes for the pilot.\n", name="notes.txt")
|
|
66
|
+
return ui.Stack(
|
|
67
|
+
ui.FileCard(
|
|
68
|
+
file=st.ref,
|
|
69
|
+
meta="added just now",
|
|
70
|
+
actions=[ui.MenuItem(id="remove", label="Remove", icon="trash-2", destructive=True)],
|
|
71
|
+
on_action=lambda s, a: s.toast(f"{a} requested"),
|
|
72
|
+
),
|
|
73
|
+
ui.FileCard(
|
|
74
|
+
name="report.pdf", size=482_000, kind="pdf", status="loading", meta="converting"
|
|
75
|
+
),
|
|
76
|
+
ui.FileCard(
|
|
77
|
+
name="broken.csv", size=12, kind="text", status="error", meta="could not parse"
|
|
78
|
+
),
|
|
79
|
+
gap=2,
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Image and Pdf
|
|
84
|
+
|
|
85
|
+
`ui.Image` and `ui.Pdf` take a `FileRef` or an outside URL. An image has a
|
|
86
|
+
`size` and a `fit`; a PDF a `page` and a `height`. The browser's own viewer
|
|
87
|
+
shows the PDF, so a form or a link inside it looks like part of the app:
|
|
88
|
+
show files the app made or the person uploaded themselves, not documents
|
|
89
|
+
from strangers.
|
|
90
|
+
|
|
91
|
+
```python live
|
|
92
|
+
import struct, zlib
|
|
93
|
+
|
|
94
|
+
from pydantic import BaseModel
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def png(w=48, h=48, rgb=(10, 126, 164)) -> bytes:
|
|
98
|
+
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
|
99
|
+
|
|
100
|
+
def chunk(tag, data):
|
|
101
|
+
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
b"\x89PNG\r\n\x1a\n"
|
|
105
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
106
|
+
+ chunk(b"IDAT", zlib.compress(raw))
|
|
107
|
+
+ chunk(b"IEND", b"")
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Pic(BaseModel):
|
|
112
|
+
ref: object = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def example(s):
|
|
116
|
+
st = state(Pic)
|
|
117
|
+
if st.ref is None:
|
|
118
|
+
st.ref = s.files.add(png(), name="square.png")
|
|
119
|
+
return ui.Row(
|
|
120
|
+
ui.Image(src=st.ref, alt="A teal square", size="sm"),
|
|
121
|
+
ui.Image(
|
|
122
|
+
src="https://picsum.photos/seed/millwork/160/96",
|
|
123
|
+
alt="An outside image",
|
|
124
|
+
size="sm",
|
|
125
|
+
fit="cover",
|
|
126
|
+
),
|
|
127
|
+
gap=4,
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A PDF the session holds shows the same way, from its bytes:
|
|
132
|
+
|
|
133
|
+
```python live
|
|
134
|
+
from pydantic import BaseModel
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def tiny_pdf(text: str) -> bytes:
|
|
138
|
+
stream = f"BT /F1 24 Tf 40 120 Td ({text}) Tj ET".encode()
|
|
139
|
+
objs = [
|
|
140
|
+
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
141
|
+
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
142
|
+
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 320 180] /Contents 4 0 R "
|
|
143
|
+
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
|
144
|
+
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
|
|
145
|
+
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
146
|
+
]
|
|
147
|
+
out = b"%PDF-1.4\n"
|
|
148
|
+
offsets = []
|
|
149
|
+
for n, body in enumerate(objs, 1):
|
|
150
|
+
offsets.append(len(out))
|
|
151
|
+
out += f"{n} 0 obj\n".encode() + body + b"\nendobj\n"
|
|
152
|
+
xref = len(out)
|
|
153
|
+
out += f"xref\n0 {len(objs) + 1}\n0000000000 65535 f \n".encode()
|
|
154
|
+
out += b"".join(f"{o:010d} 00000 n \n".encode() for o in offsets)
|
|
155
|
+
out += f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
class Doc(BaseModel):
|
|
159
|
+
ref: object = None
|
|
160
|
+
|
|
161
|
+
def example(s):
|
|
162
|
+
st = state(Doc)
|
|
163
|
+
if st.ref is None:
|
|
164
|
+
st.ref = s.files.add(tiny_pdf("Hello from millwork"), name="hello.pdf")
|
|
165
|
+
return ui.Pdf(src=st.ref, height="14rem")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Download
|
|
169
|
+
|
|
170
|
+
`ui.Download` is a button that saves a file. `content` is bytes, text, a
|
|
171
|
+
path, a callable that makes any of those on click, or a `FileRef`, which
|
|
172
|
+
saves straight away without a round trip. `s.download(filename, content)`
|
|
173
|
+
does the same from a handler, without a button.
|
|
174
|
+
|
|
175
|
+
```python live
|
|
176
|
+
def report() -> str:
|
|
177
|
+
return "name,score\nAda,0.92\nGrace,0.88\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
ui.Row(
|
|
181
|
+
ui.Download("Text", filename="hello.txt", content="Hello from millwork.\n"),
|
|
182
|
+
ui.Download(
|
|
183
|
+
"Made on click", filename="report.csv", content=report, mime="text/csv", variant="primary"
|
|
184
|
+
),
|
|
185
|
+
ui.Button(
|
|
186
|
+
"From a handler",
|
|
187
|
+
variant="outline",
|
|
188
|
+
icon="download",
|
|
189
|
+
on_click=lambda s, _: s.download("pushed.txt", "Sent by a handler.\n"),
|
|
190
|
+
),
|
|
191
|
+
gap=2,
|
|
192
|
+
wrap=True,
|
|
193
|
+
)
|
|
194
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Form
|
|
3
|
+
order: 6
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Form
|
|
7
|
+
|
|
8
|
+
`ui.Form` renders a Pydantic model as fields and hands `on_submit` a
|
|
9
|
+
validated instance. Types choose the controls: `str` an input, `int` and
|
|
10
|
+
`float` a number, `bool` a switch, `Literal` and `Enum` a select, `date` a
|
|
11
|
+
date picker, a nested model a fieldset, a list of models a repeatable list.
|
|
12
|
+
Validation errors land on the field they concern.
|
|
13
|
+
|
|
14
|
+
## From a model
|
|
15
|
+
|
|
16
|
+
`Field(title=, description=, examples=)` give a field its label, its help
|
|
17
|
+
text and its placeholder. Constraints such as `min_length`, `ge` and `le`
|
|
18
|
+
become the control's limits and the validation.
|
|
19
|
+
|
|
20
|
+
```python live
|
|
21
|
+
import datetime as dt
|
|
22
|
+
from typing import Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Project(BaseModel):
|
|
28
|
+
title: str = Field(title="Title", examples=["Q1 report"], min_length=3)
|
|
29
|
+
language: Literal["en", "nl", "de"] = Field("en", title="Language")
|
|
30
|
+
starts: dt.date | None = Field(None, title="Starts")
|
|
31
|
+
count: int = Field(3, ge=1, le=10, title="Copies", description="Between one and ten.")
|
|
32
|
+
enabled: bool = Field(True, title="Active")
|
|
33
|
+
notes: str | None = Field(None, title="Notes", max_length=500)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Saved(BaseModel):
|
|
37
|
+
last: str = "nothing yet"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def save(s, project: Project):
|
|
41
|
+
state(Saved).last = f"{project.title} ({project.language}, {project.count})"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def example(s):
|
|
45
|
+
return ui.Stack(
|
|
46
|
+
ui.Form(Project, on_submit=save, layout=2),
|
|
47
|
+
ui.Text(f"Saved: {state(Saved).last}", variant="muted"),
|
|
48
|
+
gap=4,
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Labels and overrides
|
|
53
|
+
|
|
54
|
+
`labels` maps a field's pointer, or its name, to a `label`, `help`,
|
|
55
|
+
`placeholder` and `choices`, so the model stays free of interface copy.
|
|
56
|
+
`overrides` maps a pointer to any spec key: a `widget`, `min`, `max`,
|
|
57
|
+
`step`, `rows`. `fields` picks a subset in order.
|
|
58
|
+
|
|
59
|
+
```python live
|
|
60
|
+
from pydantic import BaseModel
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Settings(BaseModel):
|
|
64
|
+
name: str = ""
|
|
65
|
+
level: int = 1
|
|
66
|
+
mood: str = "ok"
|
|
67
|
+
tags: list[Literal["a", "b", "c"]] = []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ui.Form(
|
|
71
|
+
Settings,
|
|
72
|
+
fields=["name", "mood", "level", "tags"],
|
|
73
|
+
labels={"name": {"label": "Display name", "help": "Shown to other users."}},
|
|
74
|
+
overrides={
|
|
75
|
+
"/mood": {"choices": [("ok", "Fine"), ("bad", "Not fine")]},
|
|
76
|
+
"/level": {"widget": "slider", "min": 0, "max": 5},
|
|
77
|
+
"/tags": {"widget": "multiselect"},
|
|
78
|
+
},
|
|
79
|
+
on_submit=lambda s, v: s.toast(f"{v.name or 'someone'} is {v.mood}"),
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Rejecting a valid draft
|
|
84
|
+
|
|
85
|
+
A handler that finds something wrong raises `ui.FormIssues` with a list of
|
|
86
|
+
`(pointer, message)` pairs; the form shows each at its field and keeps the
|
|
87
|
+
draft.
|
|
88
|
+
|
|
89
|
+
```python live
|
|
90
|
+
from pydantic import BaseModel, Field
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Signup(BaseModel):
|
|
94
|
+
email: str = Field(title="Email")
|
|
95
|
+
handle: str = Field(title="Handle", min_length=2)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
TAKEN = {"ada", "grace"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def register(s, v: Signup):
|
|
102
|
+
if v.handle.lower() in TAKEN:
|
|
103
|
+
raise ui.FormIssues([("/handle", "That handle is taken.")])
|
|
104
|
+
s.toast(f"Welcome, {v.handle}.", tone="success")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
ui.Form(Signup, on_submit=register, submit="Register", cancel="Clear")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Submitting from outside
|
|
111
|
+
|
|
112
|
+
`submit=None` hides the form's own button; a `ui.Button(form=key)` anywhere
|
|
113
|
+
on the page submits the form with that key.
|
|
114
|
+
|
|
115
|
+
```python live
|
|
116
|
+
from pydantic import BaseModel, Field
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Quick(BaseModel):
|
|
120
|
+
query: str = Field("", title="Query")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
ui.Row(
|
|
124
|
+
ui.Form(
|
|
125
|
+
Quick,
|
|
126
|
+
key="quick",
|
|
127
|
+
submit=None,
|
|
128
|
+
on_submit=lambda s, v: s.toast(f"Searching {v.query!r}"),
|
|
129
|
+
cls="grow",
|
|
130
|
+
),
|
|
131
|
+
ui.Button("Go", form="quick", icon="search"),
|
|
132
|
+
align="end",
|
|
133
|
+
gap=3,
|
|
134
|
+
)
|
|
135
|
+
```
|