Spaces:
Running
Running
Commit
·
ebec85b
0
Parent(s):
First version
Browse files- .gitignore +4 -0
- .vscode/launch.json +15 -0
- .vscode/settings.json +39 -0
- Dockerfile +19 -0
- README.md +37 -0
- public/img/bars.svg +52 -0
- pyproject.toml +28 -0
- src/tutorial/_01_click_to_edit.py +59 -0
- src/tutorial/_02_bulk_update.py +58 -0
- src/tutorial/_03_click_to_load.py +45 -0
- src/tutorial/_04_delete_row.py +80 -0
- src/tutorial/_05_edit_row.py +107 -0
- src/tutorial/_06_lazy_loading.py +43 -0
- src/tutorial/_07_inline_validation.py +64 -0
- src/tutorial/__init__.py +159 -0
- tests/__init__.py +1 -0
- tests/test_all.py +42 -0
- tests/test_click_to_edit.py +23 -0
- uv.lock +0 -0
.gitignore
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.sesskey
|
2 |
+
*.ipynb
|
3 |
+
__pycache__
|
4 |
+
.venv
|
.vscode/launch.json
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"version": "0.2.0",
|
3 |
+
"configurations": [
|
4 |
+
{
|
5 |
+
"name": "Python: Debug Tests",
|
6 |
+
"type": "debugpy",
|
7 |
+
"request": "launch",
|
8 |
+
"program": "${file}",
|
9 |
+
"purpose": ["debug-test"],
|
10 |
+
"console": "integratedTerminal",
|
11 |
+
"justMyCode": false,
|
12 |
+
"env": { "PYTEST_ADDOPTS": "--no-cov" }
|
13 |
+
}
|
14 |
+
]
|
15 |
+
}
|
.vscode/settings.json
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"[python]": {
|
3 |
+
"editor.formatOnSave": true,
|
4 |
+
"editor.defaultFormatter": "charliermarsh.ruff",
|
5 |
+
"editor.codeActionsOnSave": {
|
6 |
+
"source.fixAll": "explicit",
|
7 |
+
"source.organizeImports": "explicit"
|
8 |
+
}
|
9 |
+
},
|
10 |
+
"notebook.formatOnSave.enabled": true,
|
11 |
+
"notebook.codeActionsOnSave": {
|
12 |
+
// "notebook.source.fixAll": "explicit",
|
13 |
+
// "notebook.source.organizeImports": "explicit"
|
14 |
+
},
|
15 |
+
"jupyter.debugJustMyCode": false,
|
16 |
+
"python.testing.pytestArgs": ["tests"],
|
17 |
+
"python.testing.unittestEnabled": false,
|
18 |
+
"python.testing.pytestEnabled": true,
|
19 |
+
"files.exclude": {
|
20 |
+
"**/*.egg-info": true,
|
21 |
+
"**/htmlcov": true,
|
22 |
+
"**/~$*": true,
|
23 |
+
"**/.coverage.*": true,
|
24 |
+
"**/.venv": true
|
25 |
+
},
|
26 |
+
"python.analysis.autoFormatStrings": true,
|
27 |
+
|
28 |
+
"tailwindCSS.experimental.configFile": null,
|
29 |
+
"tailwindCSS.classAttributes": [
|
30 |
+
"class",
|
31 |
+
"className",
|
32 |
+
"ngClass",
|
33 |
+
"class:list",
|
34 |
+
"cls"
|
35 |
+
],
|
36 |
+
"tailwindCSS.includeLanguages": {
|
37 |
+
"python": "html"
|
38 |
+
}
|
39 |
+
}
|
Dockerfile
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.11-slim
|
2 |
+
COPY --chown=user --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
3 |
+
|
4 |
+
RUN useradd -m -u 1000 user
|
5 |
+
USER user
|
6 |
+
WORKDIR /app
|
7 |
+
|
8 |
+
|
9 |
+
ADD uv.lock /app/uv.lock
|
10 |
+
ADD pyproject.toml /app/pyproject.toml
|
11 |
+
|
12 |
+
RUN uv sync --frozen --no-install-project
|
13 |
+
|
14 |
+
COPY --chown=user src ./src
|
15 |
+
RUN touch README.md
|
16 |
+
|
17 |
+
RUN uv sync --frozen
|
18 |
+
|
19 |
+
CMD ["uv", "run", "start_tutorial"]
|
README.md
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
title: Htmx Examples
|
3 |
+
emoji: 🦀
|
4 |
+
colorFrom: red
|
5 |
+
colorTo: green
|
6 |
+
sdk: docker
|
7 |
+
pinned: false
|
8 |
+
license: mit
|
9 |
+
app_port: 5001
|
10 |
+
---
|
11 |
+
|
12 |
+
# Fasthtml Examples
|
13 |
+
|
14 |
+
The repository reproduces HTMX official [examples](https://htmx.org/examples/) in Python with [FastHTML](https://docs.fastht.ml/)
|
15 |
+
|
16 |
+
Visit the site [here](https://phihung-htmx-examples.hf.space)
|
17 |
+
|
18 |
+
Github: [link](https://github.com/phihung/fasthtml_examples)
|
19 |
+
|
20 |
+
Run
|
21 |
+
|
22 |
+
```bash
|
23 |
+
# Local
|
24 |
+
uv sync
|
25 |
+
uv run start_tutorial
|
26 |
+
|
27 |
+
# Docker
|
28 |
+
docker build -t htmx_examples .
|
29 |
+
docker run --rm -p 5001:5001 -it htmx_examples
|
30 |
+
```
|
31 |
+
|
32 |
+
## Dev
|
33 |
+
|
34 |
+
```bash
|
35 |
+
uv sync
|
36 |
+
uv run pytest
|
37 |
+
```
|
public/img/bars.svg
ADDED
|
pyproject.toml
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[project]
|
2 |
+
name = "tutorial"
|
3 |
+
version = "0.1.0"
|
4 |
+
description = "Add your description here"
|
5 |
+
readme = "README.md"
|
6 |
+
requires-python = ">=3.11"
|
7 |
+
dependencies = [
|
8 |
+
"python-fasthtml>=0.5.1",
|
9 |
+
]
|
10 |
+
|
11 |
+
[project.scripts]
|
12 |
+
start_tutorial = "tutorial:start"
|
13 |
+
|
14 |
+
[build-system]
|
15 |
+
requires = ["hatchling"]
|
16 |
+
build-backend = "hatchling.build"
|
17 |
+
|
18 |
+
[tool.uv]
|
19 |
+
dev-dependencies = [
|
20 |
+
"ipykernel>=6.29.5",
|
21 |
+
"pytest-cov>=5.0.0",
|
22 |
+
"pytest>=8.3.2",
|
23 |
+
"ruff>=0.6.3",
|
24 |
+
]
|
25 |
+
|
26 |
+
[tool.ruff]
|
27 |
+
line-length = 120
|
28 |
+
target-version = "py311"
|
src/tutorial/_01_click_to_edit.py
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from collections import namedtuple
|
2 |
+
|
3 |
+
from fasthtml.common import Button, Div, Form, Input, Label, P, fast_app
|
4 |
+
|
5 |
+
app, rt = fast_app()
|
6 |
+
|
7 |
+
Contact = namedtuple("Contact", ["name", "email"])
|
8 |
+
current = Contact("Joe", "[email protected]")
|
9 |
+
|
10 |
+
|
11 |
+
@app.get("/contact/1")
|
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="/contact/1/edit", cls="btn primary"),
|
17 |
+
hx_target="this",
|
18 |
+
hx_swap="outerHTML",
|
19 |
+
cls="container",
|
20 |
+
)
|
21 |
+
|
22 |
+
|
23 |
+
@app.get("/contact/1/edit")
|
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="/contact/1", cls="btn"),
|
30 |
+
hx_put="/contact/1",
|
31 |
+
hx_target="this",
|
32 |
+
hx_swap="outerHTML",
|
33 |
+
cls="container",
|
34 |
+
)
|
35 |
+
|
36 |
+
|
37 |
+
@app.put("/contact/1")
|
38 |
+
def put_contact(c: Contact):
|
39 |
+
global current
|
40 |
+
current = c
|
41 |
+
return get_contact()
|
42 |
+
|
43 |
+
|
44 |
+
DESC = "Demonstrates inline editing of a data object"
|
45 |
+
DOC = """
|
46 |
+
The click to edit pattern provides a way to offer inline editing of all or part of a record without a page refresh.
|
47 |
+
|
48 |
+
- This pattern starts with a UI that shows the details of a contact. The div has a button that will get the editing UI for the contact from /contact/1/edit
|
49 |
+
|
50 |
+
::get_contact::
|
51 |
+
|
52 |
+
- This returns a form that can be used to edit the contact
|
53 |
+
|
54 |
+
::contact_edit::
|
55 |
+
|
56 |
+
The form issues a PUT back to /contact/1, following the usual REST-ful pattern.
|
57 |
+
|
58 |
+
::put_contact::
|
59 |
+
"""
|
src/tutorial/_02_bulk_update.py
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fasthtml.common import Button, Div, Form, Input, Style, Table, Tbody, Td, Th, Thead, Tr, fast_app
|
2 |
+
|
3 |
+
css = """\
|
4 |
+
#toast.htmx-settling {
|
5 |
+
opacity: 100;
|
6 |
+
}
|
7 |
+
|
8 |
+
#toast {
|
9 |
+
opacity: 0;
|
10 |
+
transition: opacity 3s ease-out;
|
11 |
+
background: blue;
|
12 |
+
color: orange;
|
13 |
+
}
|
14 |
+
"""
|
15 |
+
|
16 |
+
app, rt = fast_app(hdrs=[Style(css)])
|
17 |
+
|
18 |
+
|
19 |
+
@app.get("/users")
|
20 |
+
def list_users():
|
21 |
+
return Form(
|
22 |
+
Table(
|
23 |
+
Thead(Tr(Th("Name"), Th("Email"), Th("Active"))),
|
24 |
+
Tbody(
|
25 |
+
Tr(Td("Kim 1"), Td("[email protected]"), Td(Input(type="checkbox", name="active:[email protected]"))),
|
26 |
+
Tr(Td("Kim 2"), Td("[email protected]"), Td(Input(type="checkbox", name="active:[email protected]"))),
|
27 |
+
Tr(Td("Kim 3"), Td("[email protected]"), Td(Input(type="checkbox", name="active:[email protected]"))),
|
28 |
+
Tr(Td("Kim 4"), Td("[email protected]"), Td(Input(type="checkbox", name="active:[email protected]"))),
|
29 |
+
),
|
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 bulk_update(x: dict):
|
44 |
+
n = len(x)
|
45 |
+
return Div(f"Activated {n} and deactivated {4-n} users", id="toast", aria_live="polite")
|
46 |
+
|
47 |
+
|
48 |
+
DESC = "Demonstrates bulk updating of multiple rows of data"
|
49 |
+
DOC = """
|
50 |
+
This demo shows how to implement a common pattern where rows are selected and then bulk updated. This is accomplished by putting a form around a table, with checkboxes in the table, and then including the checked values in the form submission (POST request):
|
51 |
+
|
52 |
+
The server will bulk-update the statuses based on the values of the checkboxes. We respond with a small toast message about the update to inform the user, and use ARIA to politely announce the update for accessibility.
|
53 |
+
::css::
|
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 |
+
::list_users bulk_update::
|
58 |
+
"""
|
src/tutorial/_03_click_to_load.py
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fasthtml.common import Button, Div, Table, Tbody, Td, Th, Thead, Tr, fast_app
|
2 |
+
|
3 |
+
app, rt = fast_app()
|
4 |
+
|
5 |
+
|
6 |
+
# fmt: off
|
7 |
+
@app.get("/table")
|
8 |
+
def contact_table():
|
9 |
+
return Div(
|
10 |
+
Table(
|
11 |
+
Thead(Tr(Th("Name"), Th("ID"))),
|
12 |
+
Tbody(load_contacts(page=1)),
|
13 |
+
),
|
14 |
+
cls="container overflow-auto",
|
15 |
+
)
|
16 |
+
# fmt: on
|
17 |
+
|
18 |
+
|
19 |
+
@app.get("/contacts")
|
20 |
+
def load_contacts(page: int, limit: int = 5):
|
21 |
+
rows = [Tr(Td("Smith"), Td(page * limit + i)) for i in range(limit)]
|
22 |
+
return *rows, make_last_row(page)
|
23 |
+
|
24 |
+
|
25 |
+
def make_last_row(page):
|
26 |
+
return Tr(
|
27 |
+
Td(
|
28 |
+
Button(
|
29 |
+
"Load More Agents...",
|
30 |
+
hx_get=f"/contacts?page={page + 1}",
|
31 |
+
hx_swap="outerHTML",
|
32 |
+
cls="btn primary",
|
33 |
+
),
|
34 |
+
colspan="3",
|
35 |
+
),
|
36 |
+
hx_target="this",
|
37 |
+
)
|
38 |
+
|
39 |
+
|
40 |
+
DESC = "Demonstrates clicking to load more rows in a table"
|
41 |
+
DOC = """
|
42 |
+
This example shows how to implement click-to-load the next page in a table of data. The crux of the demo is the final row:
|
43 |
+
::make_last_row load_contacts::
|
44 |
+
This row contains a button that will replace the entire row with the next page of results (which will contain a button to load the next page of results). And so on.
|
45 |
+
"""
|
src/tutorial/_04_delete_row.py
ADDED
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fasthtml.common import Button, Div, Style, Table, Tbody, Td, Th, Thead, Tr, fast_app
|
2 |
+
|
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("/table")
|
14 |
+
def contact_table():
|
15 |
+
return Div(
|
16 |
+
Table(
|
17 |
+
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
18 |
+
Tbody(
|
19 |
+
Tr(
|
20 |
+
Td("Joe Smith"),
|
21 |
+
Td("[email protected]"),
|
22 |
+
Td(Button("Delete", hx_delete="/contacts/0", cls="btn danger")),
|
23 |
+
),
|
24 |
+
Tr(
|
25 |
+
Td("Angie MacDowell"),
|
26 |
+
Td("[email protected]"),
|
27 |
+
Td(Button("Delete", hx_delete="/contacts/1", cls="btn danger")),
|
28 |
+
),
|
29 |
+
Tr(
|
30 |
+
Td("Fuqua Tarkenton"),
|
31 |
+
Td("[email protected]"),
|
32 |
+
Td(Button("Delete", hx_delete="/contacts/2", cls="btn danger")),
|
33 |
+
),
|
34 |
+
Tr(
|
35 |
+
Td("Kim Yee"),
|
36 |
+
Td("[email protected]"),
|
37 |
+
Td(Button("Delete", hx_delete="/contacts/3", cls="btn danger")),
|
38 |
+
),
|
39 |
+
hx_confirm="Are you sure?",
|
40 |
+
hx_target="closest tr",
|
41 |
+
hx_swap="outerHTML swap:1s",
|
42 |
+
),
|
43 |
+
),
|
44 |
+
cls="container overflow-auto",
|
45 |
+
)
|
46 |
+
|
47 |
+
|
48 |
+
@app.delete("/contacts/{idx}")
|
49 |
+
def delete_contact(idx: int):
|
50 |
+
# Delete actual data here
|
51 |
+
return None
|
52 |
+
|
53 |
+
|
54 |
+
DESC = "Demonstrates row deletion in a table"
|
55 |
+
DOC = """
|
56 |
+
This example shows how to implement a delete button that removes a table row upon completion. First let’s look at the table body:
|
57 |
+
```python
|
58 |
+
Table(
|
59 |
+
Thead(),
|
60 |
+
Tbody(
|
61 |
+
Tr(), Tr(), Tr(), Tr(),
|
62 |
+
hx_confirm="Are you sure?",
|
63 |
+
hx_target="closest tr",
|
64 |
+
hx_swap="outerHTML swap:1s",
|
65 |
+
)
|
66 |
+
)
|
67 |
+
```
|
68 |
+
The table body has a `hx-confirm` attribute to confirm the delete action. It also set the target to be the closest tr that is, the closest table row, for all the buttons (hx-target is inherited from parents in the DOM.) The swap specification in hx-swap says to swap the entire target out and to wait 1 second after receiving a response. This last bit is so that we can use the following CSS:
|
69 |
+
::css::
|
70 |
+
To fade the row out before it is swapped/removed.
|
71 |
+
|
72 |
+
Each row has a button with a hx-delete attribute containing the url on which to issue a DELETE request to delete the row from the server. This request responds with a 200 status code and empty content, indicating that the row should be replaced with nothing.
|
73 |
+
```python
|
74 |
+
Tr(
|
75 |
+
Td("Angie MacDowell"),
|
76 |
+
Td("[email protected]"),
|
77 |
+
Td(Button("Delete", hx_delete="/contacts/1", cls="btn danger")),
|
78 |
+
)
|
79 |
+
```
|
80 |
+
"""
|
src/tutorial/_05_edit_row.py
ADDED
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fasthtml.common import Button, Div, Input, Table, Tbody, Td, Th, Thead, Tr, fast_app
|
2 |
+
|
3 |
+
app, rt = fast_app()
|
4 |
+
|
5 |
+
DATA = [
|
6 |
+
("Joe 1", "[email protected]"),
|
7 |
+
("Joe 2", "[email protected]"),
|
8 |
+
("Joe 3", "[email protected]"),
|
9 |
+
("Joe 4", "[email protected]"),
|
10 |
+
]
|
11 |
+
|
12 |
+
|
13 |
+
@app.get("/table")
|
14 |
+
def contact_table():
|
15 |
+
return Div(
|
16 |
+
Table(
|
17 |
+
Thead(Tr(Th("Name"), Th("Email"), Th())),
|
18 |
+
Tbody(
|
19 |
+
*(get_contact(i) for i in range(4)),
|
20 |
+
hx_target="closest tr",
|
21 |
+
hx_swap="outerHTML",
|
22 |
+
),
|
23 |
+
),
|
24 |
+
cls="container-fluid",
|
25 |
+
)
|
26 |
+
|
27 |
+
|
28 |
+
@app.get("/contact/{idx}/edit")
|
29 |
+
def edit_contact(idx: int):
|
30 |
+
name, email = DATA[idx]
|
31 |
+
return Tr(
|
32 |
+
Td(Input(name="name", value=name)),
|
33 |
+
Td(Input(name="email", value=email)),
|
34 |
+
Td(
|
35 |
+
Button("Cancel", hx_get=f"/contact/{idx}", cls="btn secondary"),
|
36 |
+
Button("Save", hx_put=f"/contact/{idx}", hx_include="closest tr", cls="btn primary"),
|
37 |
+
),
|
38 |
+
hx_trigger="cancel",
|
39 |
+
hx_get=f"/contact/{idx}",
|
40 |
+
cls="editing",
|
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"])
|
57 |
+
return get_contact(idx)
|
58 |
+
|
59 |
+
|
60 |
+
JS = """
|
61 |
+
let editing = document.querySelector('.editing')
|
62 |
+
if(editing) {
|
63 |
+
htmx.trigger(editing, 'cancel');
|
64 |
+
}
|
65 |
+
htmx.trigger(this, 'edit')
|
66 |
+
"""
|
67 |
+
|
68 |
+
# JS = """
|
69 |
+
# let editing = document.querySelector('.editing')
|
70 |
+
# if(editing) {
|
71 |
+
# Swal.fire({
|
72 |
+
# title: 'Already Editing',
|
73 |
+
# showCancelButton: true,
|
74 |
+
# confirmButtonText: 'Yep, Edit This Row!',
|
75 |
+
# text:'Hey! You are already editing a row! Do you want to cancel that edit and continue?'
|
76 |
+
# })
|
77 |
+
# .then((result) => {
|
78 |
+
# if(result.isConfirmed) {
|
79 |
+
# htmx.trigger(editing, 'cancel')
|
80 |
+
# htmx.trigger(this, 'edit')
|
81 |
+
# }
|
82 |
+
# })
|
83 |
+
# } else {
|
84 |
+
# htmx.trigger(this, 'edit')
|
85 |
+
# }
|
86 |
+
# """
|
87 |
+
|
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 |
+
::contact_table::
|
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:
|
95 |
+
::get_contact::
|
96 |
+
Javascript code
|
97 |
+
::JS::
|
98 |
+
Here we are getting a bit fancy and only allowing one row at a time to be edited, using some JavaScript. We check to see if there is a row with the .editing class on it and confirm that the user wants to edit this row and dismiss the other one. If so, we send a cancel event to the other row so it will issue a request to go back to its initial state.
|
99 |
+
|
100 |
+
We then trigger the edit event on the current element, which triggers the htmx request to get the editable version of the row.
|
101 |
+
|
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_contact::
|
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
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
|
3 |
+
from fasthtml.common import Button, Div, Img, NotStr, Style, fast_app
|
4 |
+
|
5 |
+
css = """
|
6 |
+
.htmx-settling img {
|
7 |
+
opacity: 0;
|
8 |
+
}
|
9 |
+
img {
|
10 |
+
transition: opacity 300ms ease-in;
|
11 |
+
}
|
12 |
+
"""
|
13 |
+
|
14 |
+
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
+
|
16 |
+
|
17 |
+
@app.get("/page")
|
18 |
+
def main_page():
|
19 |
+
return Div(
|
20 |
+
Img(src="/img/bars.svg", alt="Result loading...", cls="htmx-indicator", width="150"),
|
21 |
+
hx_get="/content",
|
22 |
+
hx_trigger="load",
|
23 |
+
cls="container",
|
24 |
+
)
|
25 |
+
|
26 |
+
|
27 |
+
@app.get("/content")
|
28 |
+
def get_graph():
|
29 |
+
time.sleep(3)
|
30 |
+
return Div(
|
31 |
+
NotStr("<ins>This simple text takes 3s to load!</ins>"),
|
32 |
+
Button("Reload", hx_target="body", hx_get="/page"),
|
33 |
+
)
|
34 |
+
|
35 |
+
|
36 |
+
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 |
+
::main_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
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import time
|
2 |
+
|
3 |
+
from fasthtml.common import H3, Button, Div, Form, Img, Input, Label, NotStr, Span, Style, fast_app
|
4 |
+
|
5 |
+
css = """
|
6 |
+
.error input {
|
7 |
+
box-shadow: 0 0 3px #CC0000;
|
8 |
+
}
|
9 |
+
.valid input {
|
10 |
+
box-shadow: 0 0 3px #36cc00;
|
11 |
+
}
|
12 |
+
"""
|
13 |
+
|
14 |
+
app, rt = fast_app(hdrs=[Style(css)])
|
15 |
+
|
16 |
+
|
17 |
+
@app.get("/form")
|
18 |
+
def main_page():
|
19 |
+
return Div(
|
20 |
+
H3("Signup Form"),
|
21 |
+
NotStr(
|
22 |
+
"Enter an email into the input below and on tab out it will be validated. Only <ins>[email protected]</ins> will pass."
|
23 |
+
),
|
24 |
+
Form(
|
25 |
+
make_email_field("", error=False, touched=False),
|
26 |
+
Div(Label("Name"), Input(name="name")),
|
27 |
+
Button("Submit", disabled="", cls="btn primary"),
|
28 |
+
),
|
29 |
+
cls="container",
|
30 |
+
)
|
31 |
+
|
32 |
+
|
33 |
+
@app.post("/contact/email")
|
34 |
+
def validate_email(email: str):
|
35 |
+
time.sleep(2)
|
36 |
+
return make_email_field(email, email != "[email protected]", True)
|
37 |
+
|
38 |
+
|
39 |
+
def make_email_field(value: str, error: bool, touched: bool):
|
40 |
+
return Div(
|
41 |
+
Label("Email"),
|
42 |
+
Input(name="email", hx_post="/contact/email", 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",
|
46 |
+
hx_swap="outerHTML",
|
47 |
+
cls="" if not touched else "error" if error else "valid",
|
48 |
+
)
|
49 |
+
|
50 |
+
|
51 |
+
DESC = "Demonstrates how to do inline field validation"
|
52 |
+
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 |
+
::main_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 |
+
|
60 |
+
When a request occurs, it will return a partial to replace the outer div. It might look like this:
|
61 |
+
|
62 |
+
This form can be lightly styled with this CSS to give better visual feedback.
|
63 |
+
::css::
|
64 |
+
"""
|
src/tutorial/__init__.py
ADDED
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import importlib
|
2 |
+
import inspect
|
3 |
+
import re
|
4 |
+
from dataclasses import dataclass
|
5 |
+
from functools import cached_property
|
6 |
+
from pathlib import Path
|
7 |
+
from types import ModuleType
|
8 |
+
|
9 |
+
from fasthtml.common import (
|
10 |
+
H1,
|
11 |
+
A,
|
12 |
+
Code,
|
13 |
+
Div,
|
14 |
+
HighlightJS,
|
15 |
+
Iframe,
|
16 |
+
Main,
|
17 |
+
MarkdownJS,
|
18 |
+
P,
|
19 |
+
Pre,
|
20 |
+
Script,
|
21 |
+
Table,
|
22 |
+
Tbody,
|
23 |
+
Td,
|
24 |
+
Th,
|
25 |
+
Thead,
|
26 |
+
Tr,
|
27 |
+
fast_app,
|
28 |
+
serve,
|
29 |
+
)
|
30 |
+
|
31 |
+
hdrs = (
|
32 |
+
MarkdownJS(),
|
33 |
+
HighlightJS(langs=["python", "javascript", "html", "css"]),
|
34 |
+
)
|
35 |
+
app, rt = fast_app(hdrs=hdrs, static_path="public")
|
36 |
+
|
37 |
+
examples = sorted([f.stem for f in Path(__file__).parent.glob("*.py") if f.stem not in ["__init__"]])
|
38 |
+
|
39 |
+
|
40 |
+
@app.get("/")
|
41 |
+
def homepage():
|
42 |
+
ls = [get_example(name) for name in examples]
|
43 |
+
return Main(
|
44 |
+
Table(
|
45 |
+
Thead(Tr(Th("Pattern"), Th("Description"))),
|
46 |
+
Tbody(tuple(Tr(Td(A(ex.title, href="/" + ex.slug)), Td(ex.desc)) for ex in ls)),
|
47 |
+
),
|
48 |
+
cls="container",
|
49 |
+
)
|
50 |
+
|
51 |
+
|
52 |
+
def get_app():
|
53 |
+
for name in examples:
|
54 |
+
get_example(name).create_routes(app)
|
55 |
+
return app
|
56 |
+
|
57 |
+
|
58 |
+
def get_example(name):
|
59 |
+
module = importlib.import_module(f"tutorial.{name}")
|
60 |
+
return Example(module, name[4:])
|
61 |
+
|
62 |
+
|
63 |
+
@dataclass
|
64 |
+
class Example:
|
65 |
+
module: ModuleType
|
66 |
+
name: str
|
67 |
+
|
68 |
+
@cached_property
|
69 |
+
def title(self):
|
70 |
+
return self.name.replace("_", " ").title()
|
71 |
+
|
72 |
+
@cached_property
|
73 |
+
def desc(self):
|
74 |
+
return self.module.DESC
|
75 |
+
|
76 |
+
@cached_property
|
77 |
+
def doc(self):
|
78 |
+
return self.module.DOC
|
79 |
+
|
80 |
+
@cached_property
|
81 |
+
def slug(self):
|
82 |
+
return self.name.replace("_", "-")
|
83 |
+
|
84 |
+
@cached_property
|
85 |
+
def htmx_url(self):
|
86 |
+
return getattr(self.module, "HTMX_URL", f"https://htmx.org/examples/{self.slug}/")
|
87 |
+
|
88 |
+
@cached_property
|
89 |
+
def start_url(self):
|
90 |
+
module, slug = self.module, self.slug
|
91 |
+
url = getattr(module, "START_URL", module.app.routes[1].path)
|
92 |
+
return f"/{slug}{url}"
|
93 |
+
|
94 |
+
def create_routes(self, app):
|
95 |
+
module, slug = self.module, self.slug
|
96 |
+
self._fix_url()
|
97 |
+
app.mount(f"/{slug}", module.app)
|
98 |
+
app.get(f"/{slug}")(self.main_page)
|
99 |
+
|
100 |
+
def main_page(self, tab: str = "explain"):
|
101 |
+
module = self.module
|
102 |
+
if tab == "code":
|
103 |
+
code = Path(module.__file__).read_text().split("DESC = ")[0]
|
104 |
+
code = code.strip().replace("# fmt: on\n", "").replace("# fmt: off\n", "")
|
105 |
+
content = Pre(Code(code))
|
106 |
+
else:
|
107 |
+
doc = re.sub("::([a-zA-Z_0-9\s]+)::", lambda x: code_block(module, x.group(1)), self.doc)
|
108 |
+
content = Div(doc, cls="marked")
|
109 |
+
|
110 |
+
return Main(
|
111 |
+
H1(self.title),
|
112 |
+
Div(
|
113 |
+
A("Back", href="/"),
|
114 |
+
"|",
|
115 |
+
A("Explain", href=f"/{self.slug}?tab=explain"),
|
116 |
+
"|",
|
117 |
+
A("Code", href=f"/{self.slug}?tab=code"),
|
118 |
+
"|",
|
119 |
+
A("Htmx Docs", href=self.htmx_url),
|
120 |
+
),
|
121 |
+
Div(
|
122 |
+
Div(content, style="height:80vh;overflow:scroll"),
|
123 |
+
Div(P(A("Direct url", href=self.start_url)), Iframe(src=self.start_url, height="500px", width="100%")),
|
124 |
+
cls="grid",
|
125 |
+
),
|
126 |
+
cls="container",
|
127 |
+
)
|
128 |
+
|
129 |
+
def _fix_url(self):
|
130 |
+
module, slug = self.module, self.slug
|
131 |
+
code = f"""
|
132 |
+
document.addEventListener('htmx:configRequest', (event) => {{
|
133 |
+
event.detail.path = `/{slug}${{event.detail.path}}`
|
134 |
+
}})
|
135 |
+
"""
|
136 |
+
module.app.hdrs.append(Script(code))
|
137 |
+
|
138 |
+
|
139 |
+
def code_block(module, obj):
|
140 |
+
code = ""
|
141 |
+
for o in obj.strip().split():
|
142 |
+
func = getattr(module, o)
|
143 |
+
if callable(func):
|
144 |
+
func = getattr(func, "__wrapped__", func)
|
145 |
+
code += inspect.getsource(func)
|
146 |
+
else:
|
147 |
+
code += str(func).strip()
|
148 |
+
code += "\n"
|
149 |
+
code = code.strip()
|
150 |
+
return f"```\n{code.strip()}\n```"
|
151 |
+
|
152 |
+
|
153 |
+
def start():
|
154 |
+
print("hi")
|
155 |
+
serve("tutorial.__init__", app="get_app")
|
156 |
+
|
157 |
+
|
158 |
+
if __name__ == "__main__":
|
159 |
+
serve(app="get_app")
|
tests/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
|
tests/test_all.py
ADDED
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pathlib import Path
|
2 |
+
|
3 |
+
import httpx
|
4 |
+
import pytest
|
5 |
+
from starlette.testclient import TestClient
|
6 |
+
|
7 |
+
from tutorial import get_app, get_example
|
8 |
+
|
9 |
+
EXAMPLES = [f.stem for f in Path("src/tutorial").glob("*.py") if f.stem not in ["__init__"]]
|
10 |
+
|
11 |
+
|
12 |
+
@pytest.mark.parametrize("example", EXAMPLES)
|
13 |
+
def test_example_page(client, example):
|
14 |
+
m = get_example(example)
|
15 |
+
main_func = next(x for x in m.module.app.routes if m.start_url.endswith(x.path)).name
|
16 |
+
|
17 |
+
r = client.get(f"/{m.slug}")
|
18 |
+
assert r.status_code == 200
|
19 |
+
assert m.module.DOC.strip().splitlines()[0] in r.text
|
20 |
+
assert "::" not in r.text
|
21 |
+
|
22 |
+
r = client.get(f"/{m.slug}?tab=code")
|
23 |
+
assert r.status_code == 200
|
24 |
+
assert m.module.DOC.strip().splitlines()[0] not in r.text
|
25 |
+
assert f"def {main_func}" in r.text
|
26 |
+
assert "app, rt = fast_app(" in r.text
|
27 |
+
assert m.htmx_url in r.text
|
28 |
+
assert httpx.head(m.htmx_url).status_code == 200
|
29 |
+
|
30 |
+
|
31 |
+
@pytest.mark.parametrize("example", EXAMPLES)
|
32 |
+
def test_start_url(client, example):
|
33 |
+
m = get_example(example)
|
34 |
+
r = client.get(m.start_url)
|
35 |
+
assert r.status_code == 200
|
36 |
+
print(r.text)
|
37 |
+
assert "<html>" in r.text
|
38 |
+
|
39 |
+
|
40 |
+
@pytest.fixture
|
41 |
+
def client():
|
42 |
+
return TestClient(get_app())
|
tests/test_click_to_edit.py
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from starlette.testclient import TestClient
|
2 |
+
|
3 |
+
from tutorial import _01_click_to_edit as module
|
4 |
+
|
5 |
+
|
6 |
+
def test_app():
|
7 |
+
client = TestClient(module.app)
|
8 |
+
|
9 |
+
def check(r):
|
10 |
+
c = module.current
|
11 |
+
assert r.status_code == 200
|
12 |
+
assert c.name in r.text
|
13 |
+
assert c.email in r.text
|
14 |
+
|
15 |
+
r = client.get("/contact/1")
|
16 |
+
check(r)
|
17 |
+
|
18 |
+
r = client.get("/contact/1/edit")
|
19 |
+
check(r)
|
20 |
+
|
21 |
+
r = client.put("/contact/1", data={"name": "AAA", "email": "BB"})
|
22 |
+
check(r)
|
23 |
+
assert module.current.name == "AAA"
|
uv.lock
ADDED
The diff for this file is too large to render.
See raw diff
|
|