Spaces:
Running
Running
refactor: Use rt instead of hardcoded uri
Browse files- src/tutorial/_01_click_to_edit.py +11 -6
- src/tutorial/_02_bulk_update.py +6 -11
- src/tutorial/_03_click_to_load.py +3 -3
- src/tutorial/_04_delete_row.py +10 -10
- src/tutorial/_05_edit_row.py +15 -15
- src/tutorial/_06_lazy_loading.py +6 -6
- src/tutorial/_07_inline_validation.py +4 -4
- src/tutorial/_08_infinite_scroll.py +4 -4
- src/tutorial/_09_active_search.py +5 -5
- src/tutorial/_10_progress_bar.py +16 -16
- src/tutorial/_11_cascading_select.py +1 -1
- tests/test_click_to_edit.py +3 -3
- uv.lock +24 -24
src/tutorial/_01_click_to_edit.py
CHANGED
@@ -8,33 +8,38 @@ Contact = namedtuple("Contact", ["name", "email"])
|
|
8 |
current = Contact("Joe", "[email protected]")
|
9 |
|
10 |
|
11 |
-
@app.get
|
|
|
|
|
|
|
|
|
|
|
12 |
def get_contact():
|
13 |
return Div(
|
14 |
Div(P(f"Name : {current.name}")),
|
15 |
Div(P(f"Email : {current.email}")),
|
16 |
-
Button("Click To Edit", hx_get=
|
17 |
hx_target="this",
|
18 |
hx_swap="outerHTML",
|
19 |
cls="container",
|
20 |
)
|
21 |
|
22 |
|
23 |
-
@app.get("/contact/
|
24 |
def contact_edit():
|
25 |
return Form(
|
26 |
Div(Label("Name"), Input(type="text", name="name", value=current.name)),
|
27 |
Div(Label("Email"), Input(type="email", name="email", value=current.email)),
|
28 |
Button("Submit", cls="btn"),
|
29 |
-
Button("Cancel", hx_get=
|
30 |
-
hx_put=
|
31 |
hx_target="this",
|
32 |
hx_swap="outerHTML",
|
33 |
cls="container",
|
34 |
)
|
35 |
|
36 |
|
37 |
-
@app.put("/contact
|
38 |
def put_contact(c: Contact):
|
39 |
global current
|
40 |
current = c
|
|
|
8 |
current = Contact("Joe", "[email protected]")
|
9 |
|
10 |
|
11 |
+
@app.get
|
12 |
+
def page():
|
13 |
+
return get_contact()
|
14 |
+
|
15 |
+
|
16 |
+
@app.get("/contact")
|
17 |
def get_contact():
|
18 |
return Div(
|
19 |
Div(P(f"Name : {current.name}")),
|
20 |
Div(P(f"Email : {current.email}")),
|
21 |
+
Button("Click To Edit", hx_get=contact_edit.rt(), cls="btn primary"),
|
22 |
hx_target="this",
|
23 |
hx_swap="outerHTML",
|
24 |
cls="container",
|
25 |
)
|
26 |
|
27 |
|
28 |
+
@app.get("/contact/edit")
|
29 |
def contact_edit():
|
30 |
return Form(
|
31 |
Div(Label("Name"), Input(type="text", name="name", value=current.name)),
|
32 |
Div(Label("Email"), Input(type="email", name="email", value=current.email)),
|
33 |
Button("Submit", cls="btn"),
|
34 |
+
Button("Cancel", hx_get=get_contact.rt(), cls="btn"),
|
35 |
+
hx_put=put_contact.rt(),
|
36 |
hx_target="this",
|
37 |
hx_swap="outerHTML",
|
38 |
cls="container",
|
39 |
)
|
40 |
|
41 |
|
42 |
+
@app.put("/contact")
|
43 |
def put_contact(c: Contact):
|
44 |
global current
|
45 |
current = c
|
src/tutorial/_02_bulk_update.py
CHANGED
@@ -16,9 +16,10 @@ css = """\
|
|
16 |
app, rt = fast_app(hdrs=[Style(css)])
|
17 |
|
18 |
|
19 |
-
@app.get
|
20 |
-
def
|
21 |
-
|
|
|
22 |
Table(
|
23 |
Thead(Tr(Th("Name"), Th("Email"), Th("Active"))),
|
24 |
Tbody(
|
@@ -30,17 +31,11 @@ def list_users():
|
|
30 |
),
|
31 |
Button("Bulk Update", cls="btn primary"),
|
32 |
Div(id="toast"),
|
33 |
-
# Bug with default enctype: multipart/form-data
|
34 |
-
enctype="",
|
35 |
-
hx_post="/users",
|
36 |
-
hx_swap="outerHTML settle:3s",
|
37 |
-
hx_target="#toast",
|
38 |
-
cls="container",
|
39 |
)
|
40 |
|
41 |
|
42 |
@app.post("/users")
|
43 |
-
def
|
44 |
n = len(x)
|
45 |
return Div(f"Activated {n} and deactivated {4-n} users", id="toast", aria_live="polite")
|
46 |
|
@@ -54,5 +49,5 @@ The server will bulk-update the statuses based on the values of the checkboxes.
|
|
54 |
The cool thing is that, because HTML form inputs already manage their own state, we don’t need to re-render any part of the users table. The active users are already checked and the inactive ones unchecked!
|
55 |
|
56 |
You can see a working example of this code below.
|
57 |
-
::
|
58 |
"""
|
|
|
16 |
app, rt = fast_app(hdrs=[Style(css)])
|
17 |
|
18 |
|
19 |
+
@app.get
|
20 |
+
def page():
|
21 |
+
# Bug with default enctype: multipart/form-data
|
22 |
+
return Form(enctype="", hx_post=update.rt(), hx_swap="outerHTML settle:3s", hx_target="#toast", cls="container")(
|
23 |
Table(
|
24 |
Thead(Tr(Th("Name"), Th("Email"), Th("Active"))),
|
25 |
Tbody(
|
|
|
31 |
),
|
32 |
Button("Bulk Update", cls="btn primary"),
|
33 |
Div(id="toast"),
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
)
|
35 |
|
36 |
|
37 |
@app.post("/users")
|
38 |
+
def update(x: dict):
|
39 |
n = len(x)
|
40 |
return Div(f"Activated {n} and deactivated {4-n} users", id="toast", aria_live="polite")
|
41 |
|
|
|
49 |
The cool thing is that, because HTML form inputs already manage their own state, we don’t need to re-render any part of the users table. The active users are already checked and the inactive ones unchecked!
|
50 |
|
51 |
You can see a working example of this code below.
|
52 |
+
::page update::
|
53 |
"""
|
src/tutorial/_03_click_to_load.py
CHANGED
@@ -4,8 +4,8 @@ app, rt = fast_app()
|
|
4 |
|
5 |
|
6 |
# fmt: off
|
7 |
-
@app.get
|
8 |
-
def
|
9 |
return Div(
|
10 |
Table(
|
11 |
Thead(Tr(Th("Name"), Th("ID"))),
|
@@ -27,7 +27,7 @@ def make_last_row(page):
|
|
27 |
Td(
|
28 |
Button(
|
29 |
"Load More Agents...",
|
30 |
-
hx_get=
|
31 |
hx_swap="outerHTML",
|
32 |
cls="btn primary",
|
33 |
),
|
|
|
4 |
|
5 |
|
6 |
# fmt: off
|
7 |
+
@app.get
|
8 |
+
def page():
|
9 |
return Div(
|
10 |
Table(
|
11 |
Thead(Tr(Th("Name"), Th("ID"))),
|
|
|
27 |
Td(
|
28 |
Button(
|
29 |
"Load More Agents...",
|
30 |
+
hx_get=load_contacts.rt(page=page + 1),
|
31 |
hx_swap="outerHTML",
|
32 |
cls="btn primary",
|
33 |
),
|
src/tutorial/_04_delete_row.py
CHANGED
@@ -3,15 +3,15 @@ from fasthtml.common import Button, Div, Style, Table, Tbody, Td, Th, Thead, Tr,
|
|
3 |
css = """\
|
4 |
tr.htmx-swapping td {
|
5 |
opacity: 0;
|
6 |
-
transition: opacity 1s ease-out;
|
7 |
}
|
8 |
"""
|
9 |
|
10 |
app, rt = fast_app(hdrs=[Style(css)])
|
11 |
|
12 |
|
13 |
-
@app.get
|
14 |
-
def
|
15 |
return Div(
|
16 |
Table(
|
17 |
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
@@ -19,22 +19,22 @@ def contact_table():
|
|
19 |
Tr(
|
20 |
Td("Joe Smith"),
|
21 |
Td("[email protected]"),
|
22 |
-
Td(Button("Delete", hx_delete="/contacts/0", cls="btn
|
23 |
),
|
24 |
Tr(
|
25 |
Td("Angie MacDowell"),
|
26 |
Td("[email protected]"),
|
27 |
-
Td(Button("Delete", hx_delete="/contacts/1", cls="btn
|
28 |
),
|
29 |
Tr(
|
30 |
Td("Fuqua Tarkenton"),
|
31 |
Td("[email protected]"),
|
32 |
-
Td(Button("Delete", hx_delete="/contacts/2", cls="btn
|
33 |
),
|
34 |
Tr(
|
35 |
Td("Kim Yee"),
|
36 |
Td("[email protected]"),
|
37 |
-
Td(Button("Delete", hx_delete="/contacts/3", cls="btn
|
38 |
),
|
39 |
hx_confirm="Are you sure?",
|
40 |
hx_target="closest tr",
|
@@ -45,8 +45,8 @@ def contact_table():
|
|
45 |
)
|
46 |
|
47 |
|
48 |
-
@
|
49 |
-
def
|
50 |
# Delete actual data here
|
51 |
return None
|
52 |
|
@@ -74,7 +74,7 @@ Each row has a button with a hx-delete attribute containing the url on which to
|
|
74 |
Tr(
|
75 |
Td("Angie MacDowell"),
|
76 |
Td("[email protected]"),
|
77 |
-
Td(Button("Delete", hx_delete="/contacts/1", cls="btn
|
78 |
)
|
79 |
```
|
80 |
"""
|
|
|
3 |
css = """\
|
4 |
tr.htmx-swapping td {
|
5 |
opacity: 0;
|
6 |
+
transition: opacity 1s ease-out !important;
|
7 |
}
|
8 |
"""
|
9 |
|
10 |
app, rt = fast_app(hdrs=[Style(css)])
|
11 |
|
12 |
|
13 |
+
@app.get
|
14 |
+
def page():
|
15 |
return Div(
|
16 |
Table(
|
17 |
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
|
|
19 |
Tr(
|
20 |
Td("Joe Smith"),
|
21 |
Td("[email protected]"),
|
22 |
+
Td(Button("Delete", hx_delete="/contacts/0", cls="btn secondary")),
|
23 |
),
|
24 |
Tr(
|
25 |
Td("Angie MacDowell"),
|
26 |
Td("[email protected]"),
|
27 |
+
Td(Button("Delete", hx_delete="/contacts/1", cls="btn secondary")),
|
28 |
),
|
29 |
Tr(
|
30 |
Td("Fuqua Tarkenton"),
|
31 |
Td("[email protected]"),
|
32 |
+
Td(Button("Delete", hx_delete="/contacts/2", cls="btn secondary")),
|
33 |
),
|
34 |
Tr(
|
35 |
Td("Kim Yee"),
|
36 |
Td("[email protected]"),
|
37 |
+
Td(Button("Delete", hx_delete="/contacts/3", cls="btn secondary")),
|
38 |
),
|
39 |
hx_confirm="Are you sure?",
|
40 |
hx_target="closest tr",
|
|
|
45 |
)
|
46 |
|
47 |
|
48 |
+
@rt("/contacts/{idx}")
|
49 |
+
def delete(idx: int):
|
50 |
# Delete actual data here
|
51 |
return None
|
52 |
|
|
|
74 |
Tr(
|
75 |
Td("Angie MacDowell"),
|
76 |
Td("[email protected]"),
|
77 |
+
Td(Button("Delete", hx_delete="/contacts/1", cls="btn secondary")),
|
78 |
)
|
79 |
```
|
80 |
"""
|
src/tutorial/_05_edit_row.py
CHANGED
@@ -10,8 +10,8 @@ DATA = [
|
|
10 |
]
|
11 |
|
12 |
|
13 |
-
@app.get
|
14 |
-
def
|
15 |
return Div(
|
16 |
Table(
|
17 |
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
@@ -25,8 +25,18 @@ def contact_table():
|
|
25 |
)
|
26 |
|
27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
@app.get("/contact/{idx}/edit")
|
29 |
-
def
|
30 |
name, email = DATA[idx]
|
31 |
return Tr(
|
32 |
Td(Input(name="name", value=name)),
|
@@ -41,16 +51,6 @@ def edit_contact(idx: int):
|
|
41 |
)
|
42 |
|
43 |
|
44 |
-
@app.get("/contact/{idx}")
|
45 |
-
def get_contact(idx: int):
|
46 |
-
name, email = DATA[idx]
|
47 |
-
return Tr(
|
48 |
-
Td(name),
|
49 |
-
Td(email),
|
50 |
-
Td(Button("Edit", hx_get=f"/contact/{idx}/edit", hx_trigger="edit", onclick=JS)),
|
51 |
-
)
|
52 |
-
|
53 |
-
|
54 |
@app.put("/contact/{idx}")
|
55 |
def put_contact(idx: int, x: dict):
|
56 |
DATA[idx] = (x["name"], x["email"])
|
@@ -88,7 +88,7 @@ htmx.trigger(this, 'edit')
|
|
88 |
DESC = "Demonstrates how to edit rows in a table"
|
89 |
DOC = """
|
90 |
This example shows how to implement editable rows. First let’s look at the table body:
|
91 |
-
::
|
92 |
This will tell the requests from within the table to target the closest enclosing row that the request is triggered on and to replace the entire row.
|
93 |
|
94 |
Here is the HTML for a row:
|
@@ -102,6 +102,6 @@ We then trigger the edit event on the current element, which triggers the htmx r
|
|
102 |
Note that if you didn’t care if a user was editing multiple rows, you could omit the hyperscript and custom hx-trigger, and just let the normal click handling work with htmx. You could also implement mutual exclusivity by simply targeting the entire table when the Edit button was clicked. Here we wanted to show how to integrate htmx and JavaScript to solve the problem and narrow down the server interactions a bit, plus we get to use a nice SweetAlert confirm dialog.
|
103 |
|
104 |
Finally, here is what the row looks like when the data is being edited:
|
105 |
-
::
|
106 |
Here we have a few things going on: First off the row itself can respond to the cancel event, which will bring back the read-only version of the row. There is a cancel button that allows cancelling the current edit. Finally, there is a save button that issues a PUT to update the contact. Note that there is an hx-include that includes all the inputs in the closest row. Tables rows are notoriously difficult to use with forms due to HTML constraints (you can’t put a form directly inside a tr) so this makes things a bit nicer to deal with.
|
107 |
"""
|
|
|
10 |
]
|
11 |
|
12 |
|
13 |
+
@app.get
|
14 |
+
def page():
|
15 |
return Div(
|
16 |
Table(
|
17 |
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
|
|
25 |
)
|
26 |
|
27 |
|
28 |
+
@app.get("/contact/{idx}")
|
29 |
+
def get_contact(idx: int):
|
30 |
+
name, email = DATA[idx]
|
31 |
+
return Tr(
|
32 |
+
Td(name),
|
33 |
+
Td(email),
|
34 |
+
Td(Button("Edit", hx_get=f"/contact/{idx}/edit", hx_trigger="edit", onclick=JS)),
|
35 |
+
)
|
36 |
+
|
37 |
+
|
38 |
@app.get("/contact/{idx}/edit")
|
39 |
+
def edit_view(idx: int):
|
40 |
name, email = DATA[idx]
|
41 |
return Tr(
|
42 |
Td(Input(name="name", value=name)),
|
|
|
51 |
)
|
52 |
|
53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
54 |
@app.put("/contact/{idx}")
|
55 |
def put_contact(idx: int, x: dict):
|
56 |
DATA[idx] = (x["name"], x["email"])
|
|
|
88 |
DESC = "Demonstrates how to edit rows in a table"
|
89 |
DOC = """
|
90 |
This example shows how to implement editable rows. First let’s look at the table body:
|
91 |
+
::page::
|
92 |
This will tell the requests from within the table to target the closest enclosing row that the request is triggered on and to replace the entire row.
|
93 |
|
94 |
Here is the HTML for a row:
|
|
|
102 |
Note that if you didn’t care if a user was editing multiple rows, you could omit the hyperscript and custom hx-trigger, and just let the normal click handling work with htmx. You could also implement mutual exclusivity by simply targeting the entire table when the Edit button was clicked. Here we wanted to show how to integrate htmx and JavaScript to solve the problem and narrow down the server interactions a bit, plus we get to use a nice SweetAlert confirm dialog.
|
103 |
|
104 |
Finally, here is what the row looks like when the data is being edited:
|
105 |
+
::edit_view::
|
106 |
Here we have a few things going on: First off the row itself can respond to the cancel event, which will bring back the read-only version of the row. There is a cancel button that allows cancelling the current edit. Finally, there is a save button that issues a PUT to update the contact. Note that there is an hx-include that includes all the inputs in the closest row. Tables rows are notoriously difficult to use with forms due to HTML constraints (you can’t put a form directly inside a tr) so this makes things a bit nicer to deal with.
|
107 |
"""
|
src/tutorial/_06_lazy_loading.py
CHANGED
@@ -14,18 +14,18 @@ img {
|
|
14 |
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
|
16 |
|
17 |
-
@app.get
|
18 |
-
def
|
19 |
return Div(
|
20 |
Img(src="/img/bars.svg", alt="Result loading...", cls="htmx-indicator", width="150"),
|
21 |
-
hx_get=
|
22 |
hx_trigger="load",
|
23 |
cls="container",
|
24 |
)
|
25 |
|
26 |
|
27 |
-
@app.get
|
28 |
-
def
|
29 |
time.sleep(3)
|
30 |
return Div(
|
31 |
NotStr("<ins>This simple text takes 3s to load!</ins>"),
|
@@ -37,7 +37,7 @@ DESC = "Demonstrates how to lazy load content"
|
|
37 |
HTMX_URL = "https://htmx.org/examples/lazy-load/"
|
38 |
DOC = """
|
39 |
This example shows how to lazily load an element on a page. We start with an initial state that looks like this:
|
40 |
-
::
|
41 |
Which shows a progress indicator as we are loading the graph. The graph is then loaded and faded gently into view via a settling CSS transition:
|
42 |
::css::
|
43 |
"""
|
|
|
14 |
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
|
16 |
|
17 |
+
@app.get
|
18 |
+
def page():
|
19 |
return Div(
|
20 |
Img(src="/img/bars.svg", alt="Result loading...", cls="htmx-indicator", width="150"),
|
21 |
+
hx_get=get_content.rt(),
|
22 |
hx_trigger="load",
|
23 |
cls="container",
|
24 |
)
|
25 |
|
26 |
|
27 |
+
@app.get
|
28 |
+
def get_content():
|
29 |
time.sleep(3)
|
30 |
return Div(
|
31 |
NotStr("<ins>This simple text takes 3s to load!</ins>"),
|
|
|
37 |
HTMX_URL = "https://htmx.org/examples/lazy-load/"
|
38 |
DOC = """
|
39 |
This example shows how to lazily load an element on a page. We start with an initial state that looks like this:
|
40 |
+
::page::
|
41 |
Which shows a progress indicator as we are loading the graph. The graph is then loaded and faded gently into view via a settling CSS transition:
|
42 |
::css::
|
43 |
"""
|
src/tutorial/_07_inline_validation.py
CHANGED
@@ -14,8 +14,8 @@ css = """
|
|
14 |
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
|
16 |
|
17 |
-
@app.get
|
18 |
-
def
|
19 |
return Div(
|
20 |
H3("Signup Form"),
|
21 |
NotStr(
|
@@ -39,7 +39,7 @@ def validate_email(email: str):
|
|
39 |
def make_email_field(value: str, error: bool, touched: bool):
|
40 |
return Div(
|
41 |
Label("Email"),
|
42 |
-
Input(name="email", hx_post=
|
43 |
Img(id="ind", src="/img/bars.svg", cls="htmx-indicator"),
|
44 |
Span("Please enter a valid email address", style="color:red;") if error else None,
|
45 |
hx_target="this",
|
@@ -53,7 +53,7 @@ DOC = """
|
|
53 |
This example shows how to do inline field validation, in this case of an email address. To do this we need to create a form with an input that POSTs back to the server with the value to be validated and updates the DOM with the validation results.
|
54 |
|
55 |
We start with this form:
|
56 |
-
::
|
57 |
|
58 |
Note that the email div in the form has set itself as the target of the request and specified the outerHTML swap strategy, so it will be replaced entirely by the response. The input then specifies that it will POST to /contact/email for validation, when the changed event occurs (this is the default for inputs). It also specifies an indicator for the request.
|
59 |
|
|
|
14 |
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
|
16 |
|
17 |
+
@app.get
|
18 |
+
def page():
|
19 |
return Div(
|
20 |
H3("Signup Form"),
|
21 |
NotStr(
|
|
|
39 |
def make_email_field(value: str, error: bool, touched: bool):
|
40 |
return Div(
|
41 |
Label("Email"),
|
42 |
+
Input(name="email", hx_post=validate_email.rt(), hx_indicator="#ind", value=value),
|
43 |
Img(id="ind", src="/img/bars.svg", cls="htmx-indicator"),
|
44 |
Span("Please enter a valid email address", style="color:red;") if error else None,
|
45 |
hx_target="this",
|
|
|
53 |
This example shows how to do inline field validation, in this case of an email address. To do this we need to create a form with an input that POSTs back to the server with the value to be validated and updates the DOM with the validation results.
|
54 |
|
55 |
We start with this form:
|
56 |
+
::page make_email_field::
|
57 |
|
58 |
Note that the email div in the form has set itself as the target of the request and specified the outerHTML swap strategy, so it will be replaced entirely by the response. The input then specifies that it will POST to /contact/email for validation, when the changed event occurs (this is the default for inputs). It also specifies an indicator for the request.
|
59 |
|
src/tutorial/_08_infinite_scroll.py
CHANGED
@@ -4,8 +4,8 @@ app, rt = fast_app()
|
|
4 |
|
5 |
|
6 |
# fmt: off
|
7 |
-
@app.get
|
8 |
-
def
|
9 |
return Div(
|
10 |
Table(
|
11 |
Thead(Tr(Th("Name"), Th("ID"))),
|
@@ -16,7 +16,7 @@ def contact_table():
|
|
16 |
# fmt: on
|
17 |
|
18 |
|
19 |
-
@app.get
|
20 |
def load_contacts(page: int, limit: int = 5):
|
21 |
rows = [Tr(Td("Smith"), Td((page - 1) * limit + i)) for i in range(1, limit)]
|
22 |
return *rows, make_last_row(page, limit)
|
@@ -28,7 +28,7 @@ def make_last_row(page, limit):
|
|
28 |
Td(page * limit),
|
29 |
hx_trigger="revealed",
|
30 |
hx_swap="afterend",
|
31 |
-
hx_get=
|
32 |
)
|
33 |
|
34 |
|
|
|
4 |
|
5 |
|
6 |
# fmt: off
|
7 |
+
@app.get
|
8 |
+
def page():
|
9 |
return Div(
|
10 |
Table(
|
11 |
Thead(Tr(Th("Name"), Th("ID"))),
|
|
|
16 |
# fmt: on
|
17 |
|
18 |
|
19 |
+
@app.get
|
20 |
def load_contacts(page: int, limit: int = 5):
|
21 |
rows = [Tr(Td("Smith"), Td((page - 1) * limit + i)) for i in range(1, limit)]
|
22 |
return *rows, make_last_row(page, limit)
|
|
|
28 |
Td(page * limit),
|
29 |
hx_trigger="revealed",
|
30 |
hx_swap="afterend",
|
31 |
+
hx_get=load_contacts.rt(page=page + 1),
|
32 |
)
|
33 |
|
34 |
|
src/tutorial/_09_active_search.py
CHANGED
@@ -7,15 +7,15 @@ app, rt = fast_app()
|
|
7 |
|
8 |
|
9 |
# fmt: off
|
10 |
-
@app.get
|
11 |
-
def
|
12 |
return Div(
|
13 |
H3("Search Contacts"),
|
14 |
Input(
|
15 |
type="search",
|
16 |
name="query",
|
17 |
placeholder="Begin Typing To Search Users...",
|
18 |
-
hx_post=
|
19 |
hx_trigger="input changed delay:500ms, search",
|
20 |
hx_target="#search-results",
|
21 |
hx_indicator=".htmx-indicator",
|
@@ -30,7 +30,7 @@ def main_page():
|
|
30 |
# fmt: on
|
31 |
|
32 |
|
33 |
-
@app.post
|
34 |
def search(query: str, limit: int = 10):
|
35 |
time.sleep(0.5)
|
36 |
data = [x.split(",") for x in LINES if query.lower() in x.lower()]
|
@@ -45,7 +45,7 @@ DOC = """
|
|
45 |
This example actively searches a contacts database as the user enters text.
|
46 |
|
47 |
We start with a search input and an empty table:
|
48 |
-
::
|
49 |
The input issues a POST to /search on the input event and sets the body of the table to be the resulting content. Note that the keyup event could be used as well, but would not fire if the user pasted text with their mouse (or any other non-keyboard method).
|
50 |
|
51 |
We add the delay:500ms modifier to the trigger to delay sending the query until the user stops typing. Additionally, we add the changed modifier to the trigger to ensure we don’t send new queries when the user doesn’t change the value of the input (e.g. they hit an arrow key, or pasted the same value).
|
|
|
7 |
|
8 |
|
9 |
# fmt: off
|
10 |
+
@app.get
|
11 |
+
def page():
|
12 |
return Div(
|
13 |
H3("Search Contacts"),
|
14 |
Input(
|
15 |
type="search",
|
16 |
name="query",
|
17 |
placeholder="Begin Typing To Search Users...",
|
18 |
+
hx_post=search.rt(),
|
19 |
hx_trigger="input changed delay:500ms, search",
|
20 |
hx_target="#search-results",
|
21 |
hx_indicator=".htmx-indicator",
|
|
|
30 |
# fmt: on
|
31 |
|
32 |
|
33 |
+
@app.post
|
34 |
def search(query: str, limit: int = 10):
|
35 |
time.sleep(0.5)
|
36 |
data = [x.split(",") for x in LINES if query.lower() in x.lower()]
|
|
|
45 |
This example actively searches a contacts database as the user enters text.
|
46 |
|
47 |
We start with a search input and an empty table:
|
48 |
+
::page::
|
49 |
The input issues a POST to /search on the input event and sets the body of the table to be the resulting content. Note that the keyup event could be used as well, but would not fire if the user pasted text with their mouse (or any other non-keyboard method).
|
50 |
|
51 |
We add the delay:500ms modifier to the trigger to delay sending the query until the user stops typing. Additionally, we add the changed modifier to the trigger to ensure we don’t send new queries when the user doesn’t change the value of the input (e.g. they hit an arrow key, or pasted the same value).
|
src/tutorial/_10_progress_bar.py
CHANGED
@@ -31,51 +31,51 @@ app, rt = fast_app(hdrs=[Style(css)])
|
|
31 |
current = 1
|
32 |
|
33 |
|
34 |
-
@app.get
|
35 |
-
def
|
36 |
return Div(
|
37 |
H3("Start Progress"),
|
38 |
-
Button("Start Job", hx_post=
|
39 |
hx_target="this",
|
40 |
hx_swap="outerHTML",
|
41 |
)
|
42 |
|
43 |
|
44 |
-
@app.post
|
45 |
-
def
|
46 |
global current
|
47 |
current = 1
|
48 |
return Div(
|
49 |
H3("Running", role="status", id="pblabel", tabindex="-1", autofocus=""),
|
50 |
Div(
|
51 |
get_progress(),
|
52 |
-
hx_get=
|
53 |
hx_trigger="every 600ms",
|
54 |
hx_target="this",
|
55 |
hx_swap="innerHTML",
|
56 |
),
|
57 |
hx_trigger="done",
|
58 |
-
hx_get=
|
59 |
hx_swap="outerHTML",
|
60 |
hx_target="this",
|
61 |
)
|
62 |
|
63 |
|
64 |
-
@app.get
|
65 |
def get_progress():
|
66 |
global current
|
67 |
if current <= 100:
|
68 |
-
current +=
|
69 |
-
return Div(Div(style=f"width:{current -
|
70 |
return HttpHeader("HX-Trigger", "done")
|
71 |
|
72 |
|
73 |
-
@app.get
|
74 |
-
def
|
75 |
return Div(
|
76 |
H3("Complete", role="status", id="pblabel", tabindex="-1", autofocus=""),
|
77 |
Div(Div(style="width:100%", cls="progress-bar"), cls="progress"),
|
78 |
-
Button("Restart Job", hx_post=
|
79 |
hx_swap="outerHTML",
|
80 |
hx_target="this",
|
81 |
)
|
@@ -86,9 +86,9 @@ DOC = """
|
|
86 |
This example shows how to implement a smoothly scrolling progress bar.
|
87 |
|
88 |
We start with an initial state with a button that issues a POST to /start to begin the job:
|
89 |
-
::
|
90 |
This progress bar is updated every 600 milliseconds, with the “width” style attribute and aria-valuenow attributed set to current progress value. Because there is an id on the progress bar div, htmx will smoothly transition between requests by settling the style attribute into its new value. This, when coupled with CSS transitions, makes the visual transition continuous rather than jumpy.
|
91 |
-
::
|
92 |
Finally, when the process is complete, a server returns HX-Trigger: done header, which triggers an update of the UI to “Complete” state with a restart button added to the UI:
|
93 |
-
::
|
94 |
"""
|
|
|
31 |
current = 1
|
32 |
|
33 |
|
34 |
+
@app.get
|
35 |
+
def page():
|
36 |
return Div(
|
37 |
H3("Start Progress"),
|
38 |
+
Button("Start Job", hx_post=start.rt(), cls="btn primary"),
|
39 |
hx_target="this",
|
40 |
hx_swap="outerHTML",
|
41 |
)
|
42 |
|
43 |
|
44 |
+
@app.post
|
45 |
+
def start():
|
46 |
global current
|
47 |
current = 1
|
48 |
return Div(
|
49 |
H3("Running", role="status", id="pblabel", tabindex="-1", autofocus=""),
|
50 |
Div(
|
51 |
get_progress(),
|
52 |
+
hx_get=get_progress.rt(),
|
53 |
hx_trigger="every 600ms",
|
54 |
hx_target="this",
|
55 |
hx_swap="innerHTML",
|
56 |
),
|
57 |
hx_trigger="done",
|
58 |
+
hx_get=job_finished.rt(),
|
59 |
hx_swap="outerHTML",
|
60 |
hx_target="this",
|
61 |
)
|
62 |
|
63 |
|
64 |
+
@app.get
|
65 |
def get_progress():
|
66 |
global current
|
67 |
if current <= 100:
|
68 |
+
current += 20
|
69 |
+
return Div(Div(style=f"width:{current - 20}%", cls="progress-bar"), cls="progress")
|
70 |
return HttpHeader("HX-Trigger", "done")
|
71 |
|
72 |
|
73 |
+
@app.get
|
74 |
+
def job_finished():
|
75 |
return Div(
|
76 |
H3("Complete", role="status", id="pblabel", tabindex="-1", autofocus=""),
|
77 |
Div(Div(style="width:100%", cls="progress-bar"), cls="progress"),
|
78 |
+
Button("Restart Job", hx_post=start.rt(), cls="btn primary show"),
|
79 |
hx_swap="outerHTML",
|
80 |
hx_target="this",
|
81 |
)
|
|
|
86 |
This example shows how to implement a smoothly scrolling progress bar.
|
87 |
|
88 |
We start with an initial state with a button that issues a POST to /start to begin the job:
|
89 |
+
::page::
|
90 |
This progress bar is updated every 600 milliseconds, with the “width” style attribute and aria-valuenow attributed set to current progress value. Because there is an id on the progress bar div, htmx will smoothly transition between requests by settling the style attribute into its new value. This, when coupled with CSS transitions, makes the visual transition continuous rather than jumpy.
|
91 |
+
::start get_progress::
|
92 |
Finally, when the process is complete, a server returns HX-Trigger: done header, which triggers an update of the UI to “Complete” state with a restart button added to the UI:
|
93 |
+
::job_finished::
|
94 |
"""
|
src/tutorial/_11_cascading_select.py
CHANGED
@@ -30,7 +30,7 @@ def page():
|
|
30 |
)
|
31 |
|
32 |
|
33 |
-
@app.get
|
34 |
def load_models(make: str, sleep: int = 0):
|
35 |
time.sleep(sleep)
|
36 |
cars = {
|
|
|
30 |
)
|
31 |
|
32 |
|
33 |
+
@app.get
|
34 |
def load_models(make: str, sleep: int = 0):
|
35 |
time.sleep(sleep)
|
36 |
cars = {
|
tests/test_click_to_edit.py
CHANGED
@@ -12,12 +12,12 @@ def test_app():
|
|
12 |
assert c.name in r.text
|
13 |
assert c.email in r.text
|
14 |
|
15 |
-
r = client.get("/contact
|
16 |
check(r)
|
17 |
|
18 |
-
r = client.get("/contact/
|
19 |
check(r)
|
20 |
|
21 |
-
r = client.put("/contact
|
22 |
check(r)
|
23 |
assert module.current.name == "AAA"
|
|
|
12 |
assert c.name in r.text
|
13 |
assert c.email in r.text
|
14 |
|
15 |
+
r = client.get("/contact")
|
16 |
check(r)
|
17 |
|
18 |
+
r = client.get("/contact/edit")
|
19 |
check(r)
|
20 |
|
21 |
+
r = client.put("/contact", data={"name": "AAA", "email": "BB"})
|
22 |
check(r)
|
23 |
assert module.current.name == "AAA"
|
uv.lock
CHANGED
@@ -240,15 +240,15 @@ wheels = [
|
|
240 |
|
241 |
[[package]]
|
242 |
name = "fastlite"
|
243 |
-
version = "0.0.
|
244 |
source = { registry = "https://pypi.org/simple" }
|
245 |
dependencies = [
|
246 |
{ name = "fastcore" },
|
247 |
{ name = "sqlite-minutils" },
|
248 |
]
|
249 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
250 |
wheels = [
|
251 |
-
{ url = "https://files.pythonhosted.org/packages/
|
252 |
]
|
253 |
|
254 |
[[package]]
|
@@ -895,27 +895,27 @@ wheels = [
|
|
895 |
|
896 |
[[package]]
|
897 |
name = "ruff"
|
898 |
-
version = "0.6.
|
899 |
-
source = { registry = "https://pypi.org/simple" }
|
900 |
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
901 |
-
wheels = [
|
902 |
-
{ url = "https://files.pythonhosted.org/packages/
|
903 |
-
{ url = "https://files.pythonhosted.org/packages/
|
904 |
-
{ url = "https://files.pythonhosted.org/packages/
|
905 |
-
{ url = "https://files.pythonhosted.org/packages/
|
906 |
-
{ url = "https://files.pythonhosted.org/packages/
|
907 |
-
{ url = "https://files.pythonhosted.org/packages/
|
908 |
-
{ url = "https://files.pythonhosted.org/packages/
|
909 |
-
{ url = "https://files.pythonhosted.org/packages/
|
910 |
-
{ url = "https://files.pythonhosted.org/packages/
|
911 |
-
{ url = "https://files.pythonhosted.org/packages/
|
912 |
-
{ url = "https://files.pythonhosted.org/packages/
|
913 |
-
{ url = "https://files.pythonhosted.org/packages/
|
914 |
-
{ url = "https://files.pythonhosted.org/packages/
|
915 |
-
{ url = "https://files.pythonhosted.org/packages/
|
916 |
-
{ url = "https://files.pythonhosted.org/packages/
|
917 |
-
{ url = "https://files.pythonhosted.org/packages/
|
918 |
-
{ url = "https://files.pythonhosted.org/packages/
|
919 |
]
|
920 |
|
921 |
[[package]]
|
|
|
240 |
|
241 |
[[package]]
|
242 |
name = "fastlite"
|
243 |
+
version = "0.0.11"
|
244 |
source = { registry = "https://pypi.org/simple" }
|
245 |
dependencies = [
|
246 |
{ name = "fastcore" },
|
247 |
{ name = "sqlite-minutils" },
|
248 |
]
|
249 |
+
sdist = { url = "https://files.pythonhosted.org/packages/48/98/b0024670a63cfdea1561e665edee4f542fc3165eea85850ba68123fe6de7/fastlite-0.0.11.tar.gz", hash = "sha256:3ba61eeb510b14952c24ddc69947bff79324524d0e47dfb91ff1d8fe1492adfe", size = 20300 }
|
250 |
wheels = [
|
251 |
+
{ url = "https://files.pythonhosted.org/packages/e1/6b/25e0abd3f300a20e39cca2e31ca105b8b66dc6758d09e67ac97dd27b6fcb/fastlite-0.0.11-py3-none-any.whl", hash = "sha256:66984ab849ae41d85d205fba3e057c24e967525184f9ecbd7536761f5551392d", size = 16195 },
|
252 |
]
|
253 |
|
254 |
[[package]]
|
|
|
895 |
|
896 |
[[package]]
|
897 |
name = "ruff"
|
898 |
+
version = "0.6.4"
|
899 |
+
source = { registry = "https://pypi.org/simple" }
|
900 |
+
sdist = { url = "https://files.pythonhosted.org/packages/a4/55/9f485266e6326cab707369601b13e3e72eb90ba3eee2d6779549a00a0d58/ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212", size = 2469375 }
|
901 |
+
wheels = [
|
902 |
+
{ url = "https://files.pythonhosted.org/packages/e3/78/307591f81d09c8721b5e64539f287c82c81a46f46d16278eb27941ac17f9/ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258", size = 9692673 },
|
903 |
+
{ url = "https://files.pythonhosted.org/packages/69/63/ef398fcacdbd3995618ed30b5a6c809a1ebbf112ba604b3f5b8c3be464cf/ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60", size = 9481182 },
|
904 |
+
{ url = "https://files.pythonhosted.org/packages/a6/fd/8784e3bbd79bc17de0a62de05fe5165f494ff7d77cb06630d6428c2f10d2/ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f", size = 9174356 },
|
905 |
+
{ url = "https://files.pythonhosted.org/packages/6d/bc/c69db2d68ac7bfbb222c81dc43a86e0402d0063e20b13e609f7d17d81d3f/ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc", size = 10129365 },
|
906 |
+
{ url = "https://files.pythonhosted.org/packages/3b/10/8ed14ff60a4e5eb08cac0a04a9b4e8590c72d1ce4d29ef22cef97d19536d/ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617", size = 9483351 },
|
907 |
+
{ url = "https://files.pythonhosted.org/packages/a9/69/13316b8d64ffd6a43627cf0753339a7f95df413450c301a60904581bee6e/ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408", size = 10301099 },
|
908 |
+
{ url = "https://files.pythonhosted.org/packages/42/00/9623494087272643e8f02187c266638306c6829189a5bf1446968bbe438b/ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e", size = 11033216 },
|
909 |
+
{ url = "https://files.pythonhosted.org/packages/c5/31/e0c9d881db42ea1267e075c29aafe0db5a8a3024b131f952747f6234f858/ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818", size = 10618140 },
|
910 |
+
{ url = "https://files.pythonhosted.org/packages/5b/35/f1d8b746aedd4c8fde4f83397e940cc4c8fc619860ebbe3073340381a34d/ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6", size = 11606672 },
|
911 |
+
{ url = "https://files.pythonhosted.org/packages/c5/70/899b03cbb3eb48ed0507d4b32b6f7aee562bc618ef9ffda855ec98c0461a/ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa", size = 10288013 },
|
912 |
+
{ url = "https://files.pythonhosted.org/packages/17/c6/906bf895640521ca5115ccdd857b2bac42bd61facde6620fdc2efc0a4806/ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6", size = 10109473 },
|
913 |
+
{ url = "https://files.pythonhosted.org/packages/28/da/1284eb04172f8a5d42eb52fce9d643dd747ac59a4ed6c5d42729f72e934d/ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d", size = 9568817 },
|
914 |
+
{ url = "https://files.pythonhosted.org/packages/6c/e2/f8250b54edbb2e9222e22806e1bcc35a192ac18d1793ea556fa4977a843a/ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa", size = 9910840 },
|
915 |
+
{ url = "https://files.pythonhosted.org/packages/9c/7c/dcf2c10562346ecdf6f0e5f6669b2ddc9a74a72956c3f419abd6820c2aff/ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1", size = 10354263 },
|
916 |
+
{ url = "https://files.pythonhosted.org/packages/f1/94/c39d7ac5729e94788110503d928c98c203488664b0fb92c2b801cb832bec/ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523", size = 7958602 },
|
917 |
+
{ url = "https://files.pythonhosted.org/packages/6b/d2/2dee8c547bee3d4cfdd897f7b8e38510383acaff2c8130ea783b67631d72/ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58", size = 8795059 },
|
918 |
+
{ url = "https://files.pythonhosted.org/packages/07/1a/23280818aa4fa89bd0552aab10857154e1d3b90f27b5b745f09ec1ac6ad8/ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14", size = 8239636 },
|
919 |
]
|
920 |
|
921 |
[[package]]
|