phihung commited on
Commit
ebec85b
·
0 Parent(s):

First version

Browse files
.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
22
+ Td(Button("Delete", hx_delete="/contacts/0", cls="btn danger")),
23
+ ),
24
+ Tr(
25
+ Td("Angie MacDowell"),
26
27
+ Td(Button("Delete", hx_delete="/contacts/1", cls="btn danger")),
28
+ ),
29
+ Tr(
30
+ Td("Fuqua Tarkenton"),
31
32
+ Td(Button("Delete", hx_delete="/contacts/2", cls="btn danger")),
33
+ ),
34
+ Tr(
35
+ Td("Kim Yee"),
36
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
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