diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 108b55fe..20c9dc1f 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -4,6 +4,7 @@ module.exports = {
extends: [
'standard',
'eslint:recommended',
+ 'plugin:react/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
@@ -15,6 +16,9 @@ module.exports = {
'react-refresh/only-export-components': 'off', // how much effect does this have?
'@typescript-eslint/no-explicit-any': 'off',
'no-use-before-define': 'off',
+ 'react/react-in-jsx-scope': 'off',
+ 'react/prop-types': 'off',
+ 'react/display-name': 'off',
'import/order': [
'error',
{
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..0b84a243
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+# Keep GitHub Actions up to date with GitHub's Dependabot...
+# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ groups:
+ github-actions:
+ patterns:
+ - "*" # Group all Actions updates into a single larger pull request
+ schedule:
+ interval: monthly
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 325d8e55..73f4723f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v4
+ - uses: actions/setup-python@v5
with:
python-version: '3.11'
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
with:
node-version: 18
@@ -29,39 +29,80 @@ jobs:
- run: npm install
- - uses: pre-commit/action@v3.0.0
+ - uses: pre-commit/action@v3.0.1
with:
extra_args: --all-files
env:
SKIP: no-commit-to-branch
+ docs-build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ # note: PPPR_TOKEN is not available on PRs sourced from forks, but the necessary
+ # dependencies are also listed in docs.txt :)
+ - name: install
+ run: |
+ pip install --upgrade pip
+ pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python
+ pip install -r requirements/docs.txt
+ # note -- we can use these in the future when mkdocstrings-typescript and griffe-typedoc beocome publicly available
+ # pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python griffe-typedoc mkdocstrings-typescript
+ # npm install
+ # npm install -g typedoc
+ env:
+ PPPR_TOKEN: ${{ secrets.PPPR_TOKEN }}
+
+ - name: build site
+ run: mkdocs build --strict
+
test:
name: test ${{ matrix.python-version }} on ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- os: [ubuntu, macos]
+ os: [ubuntu-latest, macos-13, macos-latest]
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
-
- runs-on: ${{ matrix.os }}-latest
+ exclude:
+ # Python 3.8 and 3.9 are not available on macOS 14
+ - os: macos-13
+ python-version: '3.10'
+ - os: macos-13
+ python-version: '3.11'
+ - os: macos-13
+ python-version: '3.12'
+ - os: macos-latest
+ python-version: '3.8'
+ - os: macos-latest
+ python-version: '3.9'
+
+ runs-on: ${{ matrix.os }}
env:
PYTHON: ${{ matrix.python-version }}
OS: ${{ matrix.os }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: set up python
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r src/python-fastui/requirements/test.txt
- run: pip install -r src/python-fastui/requirements/pyproject.txt
- - run: pip install src/python-fastui
+ - run: pip install -e src/python-fastui
- run: coverage run -m pytest src
+ # display coverage and fail if it's below 80%, which shouldn't happen
+ - run: coverage report --fail-under=80
# test demo on 3.11 and 3.12, these tests are intentionally omitted from coverage
- if: matrix.python-version == '3.11' || matrix.python-version == '3.12'
@@ -69,7 +110,7 @@ jobs:
- run: coverage xml
- - uses: codecov/codecov-action@v3
+ - uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
env_vars: PYTHON,OS
@@ -78,9 +119,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- - uses: actions/setup-node@v3
+ - uses: actions/setup-node@v4
with:
node-version: 18
@@ -110,9 +151,9 @@ jobs:
id-token: write
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- - uses: actions/setup-python@v4
+ - uses: actions/setup-python@v5
with:
python-version: '3.11'
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1d8d4b62..49bbffc5 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,6 +4,7 @@ repos:
hooks:
- id: no-commit-to-branch
- id: check-yaml
+ args: ['--unsafe']
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
@@ -27,6 +28,7 @@ repos:
types_or: [javascript, jsx, ts, tsx, css, json, markdown]
entry: npm run prettier
language: system
+ exclude: '^docs/.*'
- id: js-lint
name: js-lint
types_or: [ts, tsx]
diff --git a/LICENSE b/LICENSE
index 286f4f19..e93c72cf 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2023 to present Samuel Colvin
+Copyright (c) 2023 to present Pydantic Services inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/Makefile b/Makefile
index 023f415c..d402340c 100644
--- a/Makefile
+++ b/Makefile
@@ -8,6 +8,15 @@ install:
pip install -e $(path)
pre-commit install
+
+.PHONY: install-docs
+install-docs:
+ pip install -r requirements/docs.txt
+
+# note -- mkdocstrings-typescript and griffe-typedoc are not yet publicly available
+# but the following can be added above the pip install -r requirements/docs.txt line in the future
+# pip install mkdocstrings-python mkdocstrings-typescript griffe-typedoc
+
.PHONY: update-lockfiles
update-lockfiles:
@echo "Updating requirements files using pip-compile"
@@ -46,5 +55,13 @@ typescript-models:
dev:
uvicorn demo:app --reload --reload-dir .
+.PHONY: docs
+docs:
+ mkdocs build
+
+.PHONY: serve
+serve:
+ mkdocs serve
+
.PHONY: all
all: testcov lint
diff --git a/README.md b/README.md
index 0c1e0014..070494e6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
# FastUI
+Find the documentation [here](https://docs.pydantic.dev/fastui/).
+Join the discussion in the #fastui slack channel [here](https://pydanticlogfire.slack.com/archives/C0720M7D31S)
+
[](https://github.com/pydantic/FastUI/actions?query=event%3Apush+branch%3Amain+workflow%3ACI)
[](https://pypi.python.org/pypi/fastui)
[](https://github.com/pydantic/FastUI)
@@ -70,7 +73,7 @@ def users_table() -> list[AnyComponent]:
c.Page( # Page provides a basic container for components
components=[
c.Heading(text='Users', level=2), # renders `
Users
`
- c.Table[User]( # c.Table is a generic component parameterized with the model used for rows
+ c.Table(
data=users,
# define two columns for the table
columns=[
@@ -136,7 +139,7 @@ Building an application this way has a number of significant advantages:
- You only need to write code in one place to build a new feature — add a new view, change the behavior of an existing view or alter the URL structure
- Deploying the front and backend can be completely decoupled, provided the frontend knows how to render all the components the backend is going to ask it to use, you're good to go
- You should be able to reuse a rich set of opensource components, they should end up being better tested and more reliable than anything you could build yourself, this is possible because the components need no context about how they're going to be used (note: since FastUI is brand new, this isn't true yet, hopefully we get there)
-- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema (note: this is not complete yet, see [#18](https://github.com/pydantic/FastUI/issues/18))
+- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema
In the abstract, FastUI is like the opposite of GraphQL but with the same goal — GraphQL lets frontend developers extend an application without any new backend development; FastUI lets backend developers extend an application without any new frontend development.
diff --git a/build-docs.sh b/build-docs.sh
new file mode 100755
index 00000000..e32c75ec
--- /dev/null
+++ b/build-docs.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -e
+set -x
+
+python3 -V
+
+python3 -m pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python
+python3 -m pip install -r ./requirements/docs.txt
+# note -- we can use these in the future when mkdocstrings-typescript and griffe-typedoc beocome publicly available
+# python3 -m pip install --extra-index-url https://pydantic:$PPPR_TOKEN@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python griffe-typedoc mkdocstrings-typescript
+# npm install
+# npm install -g typedoc
+
+python3 -m mkdocs build
diff --git a/bump_npm.py b/bump_npm.py
new file mode 100755
index 00000000..9f8c8a85
--- /dev/null
+++ b/bump_npm.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+
+def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> tuple[Path, str]:
+ content = package_json.read_text()
+ content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1)
+ assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'
+ if deps:
+ content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content)
+ assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}'
+
+ return package_json, content
+
+
+def main():
+ this_dir = Path(__file__).parent
+ fastui_package_json = this_dir / 'src/npm-fastui/package.json'
+ with fastui_package_json.open() as f:
+ old_version = json.load(f)['version']
+
+ rest, patch_version = old_version.rsplit('.', 1)
+ new_version = f'{rest}.{int(patch_version) + 1}'
+ bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json'
+ prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json'
+ to_update: list[tuple[Path, str]] = [
+ replace_package_json(fastui_package_json, new_version),
+ replace_package_json(bootstrap_package_json, new_version, deps=True),
+ replace_package_json(prebuilt_package_json, new_version),
+ ]
+
+ python_init = this_dir / 'src/python-fastui/fastui/__init__.py'
+ python_content = python_init.read_text()
+ python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content)
+ assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}'
+ to_update.append((python_init, python_content))
+
+ # logic is finished, no update all files
+ print(f'Updating files:')
+ for package_json, content in to_update:
+ print(f' {package_json.relative_to(this_dir)}')
+ package_json.write_text(content)
+
+ print(f"""
+Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files.
+
+To publish the new version, run:
+
+> npm --workspaces publish
+""")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/demo/__init__.py b/demo/__init__.py
index 616139d2..3f58bf24 100644
--- a/demo/__init__.py
+++ b/demo/__init__.py
@@ -6,12 +6,12 @@
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastui import prebuilt_html
+from fastui.auth import fastapi_auth_exception_handling
from fastui.dev import dev_fastapi_app
from httpx import AsyncClient
from .auth import router as auth_router
from .components_list import router as components_router
-from .db import create_db
from .forms import router as forms_router
from .main import router as main_router
from .sse import router as sse_router
@@ -20,7 +20,6 @@
@asynccontextmanager
async def lifespan(app_: FastAPI):
- await create_db()
async with AsyncClient() as client:
app_.state.httpx_client = client
yield
@@ -33,6 +32,7 @@ async def lifespan(app_: FastAPI):
else:
app = FastAPI(lifespan=lifespan)
+fastapi_auth_exception_handling(app)
app.include_router(components_router, prefix='/api/components')
app.include_router(sse_router, prefix='/api/components')
app.include_router(table_router, prefix='/api/table')
diff --git a/demo/auth.py b/demo/auth.py
index b9ba8da5..065d222b 100644
--- a/demo/auth.py
+++ b/demo/auth.py
@@ -1,49 +1,111 @@
from __future__ import annotations as _annotations
-from typing import Annotated
+import asyncio
+import json
+import os
+from dataclasses import asdict
+from typing import Annotated, Literal, TypeAlias
-from fastapi import APIRouter, Depends, Header
+from fastapi import APIRouter, Depends, Request
from fastui import AnyComponent, FastUI
from fastui import components as c
+from fastui.auth import AuthRedirect, GitHubAuthProvider
from fastui.events import AuthEvent, GoToEvent, PageEvent
from fastui.forms import fastui_form
+from httpx import AsyncClient
from pydantic import BaseModel, EmailStr, Field, SecretStr
-from . import db
+from .auth_user import User
from .shared import demo_page
router = APIRouter()
+GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '0d0315f9c2e055d032e2')
+# this will give an error when making requests to GitHub, but at least the app will run
+GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret'))
+# use 'http://localhost:3000/auth/login/github/redirect' in development
+GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT')
+
+
+async def get_github_auth(request: Request) -> GitHubAuthProvider:
+ client: AsyncClient = request.app.state.httpx_client
+ return GitHubAuthProvider(
+ httpx_client=client,
+ github_client_id=GITHUB_CLIENT_ID,
+ github_client_secret=GITHUB_CLIENT_SECRET,
+ redirect_uri=GITHUB_REDIRECT,
+ scopes=['user:email'],
+ )
+
+
+LoginKind: TypeAlias = Literal['password', 'github']
-async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None:
- try:
- token = authorization.split(' ', 1)[1]
- except IndexError:
- return None
- else:
- return await db.get_user(token)
-
-
-@router.get('/login', response_model=FastUI, response_model_exclude_none=True)
-def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]:
- if user is None:
- return demo_page(
- c.Paragraph(
- text=(
- 'This is a very simple demo of authentication, '
- 'here you can "login" with any email address and password.'
- )
- ),
- c.Heading(text='Login'),
- c.ModelForm(model=LoginForm, submit_url='/api/auth/login'),
- title='Authentication',
- )
- else:
- return [c.FireEvent(event=GoToEvent(url='/auth/profile'))]
+
+@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True)
+def auth_login(
+ kind: LoginKind,
+ user: Annotated[User | None, Depends(User.from_request_opt)],
+) -> list[AnyComponent]:
+ if user is not None:
+ # already logged in
+ raise AuthRedirect('/auth/profile')
+
+ return demo_page(
+ c.LinkList(
+ links=[
+ c.Link(
+ components=[c.Text(text='Password Login')],
+ on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}),
+ active='/auth/login/password',
+ ),
+ c.Link(
+ components=[c.Text(text='GitHub Login')],
+ on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}),
+ active='/auth/login/github',
+ ),
+ ],
+ mode='tabs',
+ class_name='+ mb-4',
+ ),
+ c.ServerLoad(
+ path='/auth/login/content/{kind}',
+ load_trigger=PageEvent(name='tab'),
+ components=auth_login_content(kind),
+ ),
+ title='Authentication',
+ )
+
+
+@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
+def auth_login_content(kind: LoginKind) -> list[AnyComponent]:
+ match kind:
+ case 'password':
+ return [
+ c.Heading(text='Password Login', level=3),
+ c.Paragraph(
+ text=(
+ 'This is a very simple demo of password authentication, '
+ 'here you can "login" with any email address and password.'
+ )
+ ),
+ c.Paragraph(text='(Passwords are not saved and is email stored in the browser via a JWT only)'),
+ c.ModelForm(model=LoginForm, submit_url='/api/auth/login', display_mode='page'),
+ ]
+ case 'github':
+ return [
+ c.Heading(text='GitHub Login', level=3),
+ c.Paragraph(text='Demo of GitHub authentication.'),
+ c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'),
+ c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')),
+ ]
+ case _:
+ raise ValueError(f'Invalid kind {kind!r}')
class LoginForm(BaseModel):
- email: EmailStr = Field(title='Email Address', description='Enter whatever value you like')
+ email: EmailStr = Field(
+ title='Email Address', description='Enter whatever value you like', json_schema_extra={'autocomplete': 'email'}
+ )
password: SecretStr = Field(
title='Password',
description='Enter whatever value you like, password is not checked',
@@ -53,31 +115,55 @@ class LoginForm(BaseModel):
@router.post('/login', response_model=FastUI, response_model_exclude_none=True)
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]:
- token = await db.create_user(form.email)
+ user = User(email=form.email, extra={})
+ token = user.encode_token()
return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]
@router.get('/profile', response_model=FastUI, response_model_exclude_none=True)
-async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
- if user is None:
- return [c.FireEvent(event=GoToEvent(url='/auth/login'))]
- else:
- active_count = await db.count_users()
- return demo_page(
- c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'),
- c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
- c.Form(
- submit_url='/api/auth/logout',
- form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
- footer=[],
- submit_trigger=PageEvent(name='submit-form'),
- ),
- title='Authentication',
- )
+async def profile(user: Annotated[User, Depends(User.from_request)]) -> list[AnyComponent]:
+ return demo_page(
+ c.Paragraph(text=f'You are logged in as "{user.email}".'),
+ c.Button(text='Logout', on_click=PageEvent(name='submit-form')),
+ c.Heading(text='User Data:', level=3),
+ c.Code(language='json', text=json.dumps(asdict(user), indent=2)),
+ c.Form(
+ submit_url='/api/auth/logout',
+ form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')],
+ footer=[],
+ submit_trigger=PageEvent(name='submit-form'),
+ ),
+ title='Authentication',
+ )
@router.post('/logout', response_model=FastUI, response_model_exclude_none=True)
-async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]:
- if user is not None:
- await db.delete_user(user)
- return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))]
+async def logout_form_post() -> list[AnyComponent]:
+ return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))]
+
+
+@router.get('/login/github/gen', response_model=FastUI, response_model_exclude_none=True)
+async def auth_github_gen(github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]) -> list[AnyComponent]:
+ auth_url = await github_auth.authorization_url()
+ return [c.FireEvent(event=GoToEvent(url=auth_url))]
+
+
+@router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True)
+async def github_redirect(
+ code: str,
+ state: str | None,
+ github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)],
+) -> list[AnyComponent]:
+ exchange = await github_auth.exchange_code(code, state)
+ user_info, emails = await asyncio.gather(
+ github_auth.get_github_user(exchange), github_auth.get_github_user_emails(exchange)
+ )
+ user = User(
+ email=next((e.email for e in emails if e.primary and e.verified), None),
+ extra={
+ 'github_user_info': user_info.model_dump(),
+ 'github_emails': [e.model_dump() for e in emails],
+ },
+ )
+ token = user.encode_token()
+ return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))]
diff --git a/demo/auth_user.py b/demo/auth_user.py
new file mode 100644
index 00000000..c711cc95
--- /dev/null
+++ b/demo/auth_user.py
@@ -0,0 +1,56 @@
+import json
+from dataclasses import asdict, dataclass
+from datetime import datetime, timedelta
+from typing import Annotated, Any
+
+import jwt
+from fastapi import Header, HTTPException
+from fastui.auth import AuthRedirect
+from typing_extensions import Self
+
+JWT_SECRET = 'secret'
+
+
+@dataclass
+class User:
+ email: str | None
+ extra: dict[str, Any]
+
+ def encode_token(self) -> str:
+ payload = asdict(self)
+ payload['exp'] = datetime.now() + timedelta(hours=1)
+ return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder)
+
+ @classmethod
+ def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self:
+ user = cls.from_request_opt(authorization)
+ if user is None:
+ raise AuthRedirect('/auth/login/password')
+ else:
+ return user
+
+ @classmethod
+ def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self | None:
+ try:
+ token = authorization.split(' ', 1)[1]
+ except IndexError:
+ return None
+
+ try:
+ payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
+ except jwt.ExpiredSignatureError:
+ return None
+ except jwt.DecodeError:
+ raise HTTPException(status_code=401, detail='Invalid token')
+ else:
+ # existing token might not have 'exp' field
+ payload.pop('exp', None)
+ return cls(**payload)
+
+
+class CustomJsonEncoder(json.JSONEncoder):
+ def default(self, obj: Any) -> Any:
+ if isinstance(obj, datetime):
+ return obj.isoformat()
+ else:
+ return super().default(obj)
diff --git a/demo/components_list.py b/demo/components_list.py
index c47e2294..41a653c5 100644
--- a/demo/components_list.py
+++ b/demo/components_list.py
@@ -78,6 +78,10 @@ class Delivery(BaseModel):
components=[c.Text(text='Pydantic (External link)')],
on_click=GoToEvent(url='https://pydantic.dev'),
),
+ c.Link(
+ components=[c.Text(text='FastUI repo (New tab)')],
+ on_click=GoToEvent(url='https://github.com/pydantic/FastUI', target='_blank'),
+ ),
],
),
],
@@ -88,6 +92,8 @@ class Delivery(BaseModel):
c.Heading(text='Button and Modal', level=2),
c.Paragraph(text='The button below will open a modal with static content.'),
c.Button(text='Show Static Modal', on_click=PageEvent(name='static-modal')),
+ c.Button(text='Secondary Button', named_style='secondary', class_name='+ ms-2'),
+ c.Button(text='Warning Button', named_style='warning', class_name='+ ms-2'),
c.Modal(
title='Static Modal',
body=[c.Paragraph(text='This is some static content that was set when the modal was defined.')],
@@ -120,6 +126,56 @@ class Delivery(BaseModel):
],
class_name='border-top mt-3 pt-1',
),
+ c.Div(
+ components=[
+ c.Heading(text='Modal Form / Confirm prompt', level=2),
+ c.Markdown(text='The button below will open a modal with a form.'),
+ c.Button(text='Show Modal Form', on_click=PageEvent(name='modal-form')),
+ c.Modal(
+ title='Modal Form',
+ body=[
+ c.Paragraph(text='Form inside a modal!'),
+ c.Form(
+ form_fields=[
+ c.FormFieldInput(name='foobar', title='Foobar', required=True),
+ ],
+ submit_url='/api/components/modal-form',
+ footer=[],
+ submit_trigger=PageEvent(name='modal-form-submit'),
+ ),
+ ],
+ footer=[
+ c.Button(
+ text='Cancel', named_style='secondary', on_click=PageEvent(name='modal-form', clear=True)
+ ),
+ c.Button(text='Submit', on_click=PageEvent(name='modal-form-submit')),
+ ],
+ open_trigger=PageEvent(name='modal-form'),
+ ),
+ c.Button(text='Show Modal Prompt', on_click=PageEvent(name='modal-prompt'), class_name='+ ms-2'),
+ c.Modal(
+ title='Form Prompt',
+ body=[
+ c.Paragraph(text='Are you sure you want to do whatever?'),
+ c.Form(
+ form_fields=[],
+ submit_url='/api/components/modal-prompt',
+ loading=[c.Spinner(text='Okay, good luck...')],
+ footer=[],
+ submit_trigger=PageEvent(name='modal-form-submit'),
+ ),
+ ],
+ footer=[
+ c.Button(
+ text='Cancel', named_style='secondary', on_click=PageEvent(name='modal-prompt', clear=True)
+ ),
+ c.Button(text='Submit', on_click=PageEvent(name='modal-form-submit')),
+ ],
+ open_trigger=PageEvent(name='modal-prompt'),
+ ),
+ ],
+ class_name='border-top mt-3 pt-1',
+ ),
c.Div(
components=[
c.Heading(text='Server Load', level=2),
@@ -186,6 +242,19 @@ class Delivery(BaseModel):
],
class_name='border-top mt-3 pt-1',
),
+ c.Div(
+ components=[
+ c.Heading(text='Spinner', level=2),
+ c.Paragraph(
+ text=(
+ 'A component displayed while waiting for content to load, '
+ 'this is also used automatically while loading server content.'
+ )
+ ),
+ c.Spinner(text='Content incoming...'),
+ ],
+ class_name='border-top mt-3 pt-1',
+ ),
c.Div(
components=[
c.Heading(text='Video', level=2),
@@ -221,6 +290,20 @@ class Delivery(BaseModel):
],
class_name='border-top mt-3 pt-1',
),
+ c.Div(
+ components=[
+ c.Heading(text='Button and Toast', level=2),
+ c.Paragraph(text='The button below will open a toast.'),
+ c.Button(text='Show Toast', on_click=PageEvent(name='show-toast')),
+ c.Toast(
+ title='Toast',
+ body=[c.Paragraph(text='This is a toast.')],
+ open_trigger=PageEvent(name='show-toast'),
+ position='bottom-end',
+ ),
+ ],
+ class_name='border-top mt-3 pt-1',
+ ),
title='Components',
)
@@ -229,3 +312,15 @@ class Delivery(BaseModel):
async def modal_view() -> list[AnyComponent]:
await asyncio.sleep(0.5)
return [c.Paragraph(text='This is some dynamic content. Open devtools to see me being fetched from the server.')]
+
+
+@router.post('/modal-form', response_model=FastUI, response_model_exclude_none=True)
+async def modal_form_submit() -> list[AnyComponent]:
+ await asyncio.sleep(0.5)
+ return [c.FireEvent(event=PageEvent(name='modal-form', clear=True))]
+
+
+@router.post('/modal-prompt', response_model=FastUI, response_model_exclude_none=True)
+async def modal_prompt_submit() -> list[AnyComponent]:
+ await asyncio.sleep(0.5)
+ return [c.FireEvent(event=PageEvent(name='modal-prompt', clear=True))]
diff --git a/demo/db.py b/demo/db.py
deleted file mode 100644
index c3932518..00000000
--- a/demo/db.py
+++ /dev/null
@@ -1,73 +0,0 @@
-import os
-import secrets
-from contextlib import asynccontextmanager
-from dataclasses import dataclass
-from datetime import datetime
-
-import libsql_client
-
-
-@dataclass
-class User:
- token: str
- email: str
- last_active: datetime
-
-
-async def get_user(token: str) -> User | None:
- async with _connect() as conn:
- rs = await conn.execute('select * from users where token = ?', (token,))
- if rs.rows:
- await conn.execute('update users set last_active = current_timestamp where token = ?', (token,))
- return User(*rs.rows[0])
-
-
-async def create_user(email: str) -> str:
- async with _connect() as conn:
- await _delete_old_users(conn)
- token = secrets.token_hex()
- await conn.execute('insert into users (token, email) values (?, ?)', (token, email))
- return token
-
-
-async def delete_user(user: User) -> None:
- async with _connect() as conn:
- await conn.execute('delete from users where token = ?', (user.token,))
-
-
-async def count_users() -> int:
- async with _connect() as conn:
- await _delete_old_users(conn)
- rs = await conn.execute('select count(*) from users')
- return rs.rows[0][0]
-
-
-async def create_db() -> None:
- async with _connect() as conn:
- rs = await conn.execute("select 1 from sqlite_master where type='table' and name='users'")
- if not rs.rows:
- await conn.execute(SCHEMA)
-
-
-SCHEMA = """
-create table if not exists users (
- token varchar(255) primary key,
- email varchar(255) not null unique,
- last_active timestamp not null default current_timestamp
-);
-"""
-
-
-async def _delete_old_users(conn: libsql_client.Client) -> None:
- await conn.execute('delete from users where last_active < datetime(current_timestamp, "-1 hour")')
-
-
-@asynccontextmanager
-async def _connect() -> libsql_client.Client:
- auth_token = os.getenv('SQLITE_AUTH_TOKEN')
- if auth_token:
- url = 'libsql://fastui-samuelcolvin.turso.io'
- else:
- url = 'file:users.db'
- async with libsql_client.create_client(url, auth_token=auth_token) as conn:
- yield conn
diff --git a/demo/forms.py b/demo/forms.py
index c94a60db..89716389 100644
--- a/demo/forms.py
+++ b/demo/forms.py
@@ -9,7 +9,7 @@
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import GoToEvent, PageEvent
-from fastui.forms import FormFile, SelectSearchResponse, fastui_form
+from fastui.forms import FormFile, SelectSearchResponse, Textarea, fastui_form
from httpx import AsyncClient
from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError
@@ -85,19 +85,19 @@ def form_content(kind: FormKind):
return [
c.Heading(text='Login Form', level=2),
c.Paragraph(text='Simple login form with email and password.'),
- c.ModelForm(model=LoginForm, submit_url='/api/forms/login'),
+ c.ModelForm(model=LoginForm, display_mode='page', submit_url='/api/forms/login'),
]
case 'select':
return [
c.Heading(text='Select Form', level=2),
c.Paragraph(text='Form showing different ways of doing select.'),
- c.ModelForm(model=SelectForm, submit_url='/api/forms/select'),
+ c.ModelForm(model=SelectForm, display_mode='page', submit_url='/api/forms/select'),
]
case 'big':
return [
c.Heading(text='Large Form', level=2),
c.Paragraph(text='Form with a lot of fields.'),
- c.ModelForm(model=BigModel, submit_url='/api/forms/big'),
+ c.ModelForm(model=BigModel, display_mode='page', submit_url='/api/forms/big'),
]
case _:
raise ValueError(f'Invalid kind {kind!r}')
@@ -127,6 +127,14 @@ class SelectForm(BaseModel):
search_select_single: str = Field(json_schema_extra={'search_url': '/api/forms/search'})
search_select_multiple: list[str] = Field(json_schema_extra={'search_url': '/api/forms/search'})
+ @field_validator('select_multiple', 'search_select_multiple', mode='before')
+ @classmethod
+ def correct_select_multiple(cls, v: list[str]) -> list[str]:
+ if isinstance(v, list):
+ return v
+ else:
+ return [v]
+
@router.post('/select', response_model=FastUI, response_model_exclude_none=True)
async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]):
@@ -143,6 +151,8 @@ class BigModel(BaseModel):
name: str | None = Field(
None, description='This field is not required, it must start with a capital letter if provided'
)
+ info: Annotated[str | None, Textarea(rows=5)] = Field(None, description='Optional free text information about you.')
+ repo: str = Field(json_schema_extra={'placeholder': '{org}/{repo}'}, title='GitHub repository')
profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] = Field(
description='Upload a profile picture, must not be more than 16kb'
)
diff --git a/demo/main.py b/demo/main.py
index 118d1707..370651c8 100644
--- a/demo/main.py
+++ b/demo/main.py
@@ -16,6 +16,8 @@ def api_index() -> list[AnyComponent]:
This site provides a demo of [FastUI](https://github.com/pydantic/FastUI), the code for the demo
is [here](https://github.com/pydantic/FastUI/tree/main/demo).
+You can find the documentation for FastUI [here](https://docs.pydantic.dev/fastui/).
+
The following components are demonstrated:
* `Markdown` — that's me :-)
@@ -28,15 +30,21 @@ def api_index() -> list[AnyComponent]:
* `Link` — example [here](/components#link-list)
* `LinkList` — example [here](/components#link-list)
* `Navbar` — see the top of this page
+* `Footer` — see the bottom of this page
* `Modal` — static example [here](/components#button-and-modal), dynamic content example [here](/components#dynamic-modal)
* `ServerLoad` — see [dynamic modal example](/components#dynamic-modal) and [SSE example](/components#server-load-sse)
* `Image` - example [here](/components#image)
* `Iframe` - example [here](/components#iframe)
* `Video` - example [here](/components#video)
* `DarkMode` — example [here](/components#darkmode)
+* `Toast` - example [here](/components#toast)
* `Table` — See [cities table](/table/cities) and [users table](/table/users)
* `Pagination` — See the bottom of the [cities table](/table/cities)
* `ModelForm` — See [forms](/forms/login)
+
+Authentication is supported via:
+* token based authentication — see [here](/auth/login/password) for an example of password authentication
+* GitHub OAuth — see [here](/auth/login/github) for an example of GitHub OAuth login
"""
return demo_page(c.Markdown(text=markdown))
diff --git a/demo/shared.py b/demo/shared.py
index 731d54ff..70b44de4 100644
--- a/demo/shared.py
+++ b/demo/shared.py
@@ -11,7 +11,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
c.Navbar(
title='FastUI Demo',
title_event=GoToEvent(url='/'),
- links=[
+ start_links=[
c.Link(
components=[c.Text(text='Components')],
on_click=GoToEvent(url='/components'),
@@ -24,7 +24,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
),
c.Link(
components=[c.Text(text='Auth')],
- on_click=GoToEvent(url='/auth/login'),
+ on_click=GoToEvent(url='/auth/login/password'),
active='startswith:/auth',
),
c.Link(
@@ -40,4 +40,14 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
*components,
],
),
+ c.Footer(
+ extra_text='FastUI Demo',
+ links=[
+ c.Link(
+ components=[c.Text(text='Github')], on_click=GoToEvent(url='https://github.com/pydantic/FastUI')
+ ),
+ c.Link(components=[c.Text(text='PyPI')], on_click=GoToEvent(url='https://pypi.org/project/fastui/')),
+ c.Link(components=[c.Text(text='NPM')], on_click=GoToEvent(url='https://www.npmjs.com/org/pydantic/')),
+ ],
+ ),
]
diff --git a/demo/sse.py b/demo/sse.py
index 068952c4..bcdb34b2 100644
--- a/demo/sse.py
+++ b/demo/sse.py
@@ -13,18 +13,11 @@
async def canned_ai_response_generator() -> AsyncIterable[str]:
prompt = '**User:** What is SSE? Please include a javascript code example.\n\n**AI:** '
output = ''
- msg = ''
for time, text in chain([(0.5, prompt)], CANNED_RESPONSE):
await asyncio.sleep(time)
output += text
m = FastUI(root=[c.Markdown(text=output)])
- msg = f'data: {m.model_dump_json(by_alias=True, exclude_none=True)}\n\n'
- yield msg
-
- # avoid the browser reconnecting
- while True:
- yield msg
- await asyncio.sleep(10)
+ yield f'data: {m.model_dump_json(by_alias=True, exclude_none=True)}\n\n'
@router.get('/sse')
diff --git a/demo/tables.py b/demo/tables.py
index d78bc8e4..0e851453 100644
--- a/demo/tables.py
+++ b/demo/tables.py
@@ -96,11 +96,12 @@ class User(BaseModel):
name: str = Field(title='Name')
dob: date = Field(title='Date of Birth')
enabled: bool | None = None
+ status_markdown: str | None = Field(default=None, title='Status')
users: list[User] = [
- User(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
- User(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
+ User(id=1, name='John', dob=date(1990, 1, 1), enabled=True, status_markdown='**Active**'),
+ User(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False, status_markdown='*Inactive*'),
User(id=3, name='Jack', dob=date(1992, 1, 1)),
]
@@ -115,6 +116,7 @@ def users_view() -> list[AnyComponent]:
DisplayLookup(field='name', on_click=GoToEvent(url='/table/users/{id}/')),
DisplayLookup(field='dob', mode=DisplayMode.date),
DisplayLookup(field='enabled'),
+ DisplayLookup(field='status_markdown', mode=DisplayMode.markdown),
],
),
title='Users',
diff --git a/demo/tests.py b/demo/tests.py
index 3865cdb6..f1cab0ab 100644
--- a/demo/tests.py
+++ b/demo/tests.py
@@ -6,17 +6,21 @@
from . import app
-client = TestClient(app)
+@pytest.fixture
+def client():
+ with TestClient(app) as test_client:
+ yield test_client
-def test_index():
+
+def test_index(client: TestClient):
r = client.get('/')
assert r.status_code == 200, r.text
assert r.text.startswith('\n')
assert r.headers.get('content-type') == 'text/html; charset=utf-8'
-def test_api_root():
+def test_api_root(client: TestClient):
r = client.get('/api/')
assert r.status_code == 200
data = r.json()
@@ -28,7 +32,8 @@ def test_api_root():
{
'title': 'FastUI Demo',
'titleEvent': {'url': '/', 'type': 'go-to'},
- 'links': IsList(length=4),
+ 'startLinks': IsList(length=4),
+ 'endLinks': [],
'type': 'Navbar',
},
{
@@ -40,6 +45,11 @@ def test_api_root():
],
'type': 'Page',
},
+ {
+ 'extraText': 'FastUI Demo',
+ 'links': IsList(length=3),
+ 'type': 'Footer',
+ },
]
@@ -47,20 +57,36 @@ def get_menu_links():
"""
This is pretty cursory, we just go through the menu and load each page.
"""
- r = client.get('/api/')
- assert r.status_code == 200
- data = r.json()
- for link in data[1]['links']:
- url = link['onClick']['url']
- yield pytest.param(f'/api{url}', id=url)
+ with TestClient(app) as client:
+ r = client.get('/api/')
+ assert r.status_code == 200
+ data = r.json()
+ for link in data[1]['startLinks']:
+ url = link['onClick']['url']
+ yield pytest.param(f'/api{url}', id=url)
@pytest.mark.parametrize('url', get_menu_links())
-def test_menu_links(url: str):
+def test_menu_links(client: TestClient, url: str):
r = client.get(url)
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
+# def test_forms_validate_correct_select_multiple(client: TestClient):
+# countries = client.get('api/forms/search', params={'q': None})
+# countries_options = countries.json()['options']
+# r = client.post(
+# 'api/forms/select',
+# data={
+# 'select_single': ToolEnum._member_names_[0],
+# 'select_multiple': ToolEnum._member_names_[0],
+# 'search_select_single': countries_options[0]['options'][0]['value'],
+# 'search_select_multiple': countries_options[0]['options'][0]['value'],
+# },
+# )
+# assert r.status_code == 200
+
+
# TODO tests for forms, including submission
diff --git a/docs/api/python_components.md b/docs/api/python_components.md
new file mode 100644
index 00000000..1d8b5663
--- /dev/null
+++ b/docs/api/python_components.md
@@ -0,0 +1,47 @@
+# Python Components
+
+::: fastui.components
+ handler: python
+ options:
+ inherited_members: true
+ docstring_options:
+ ignore_init_summary: false
+ members:
+ - Text
+ - Paragraph
+ - PageTitle
+ - Div
+ - Page
+ - Heading
+ - Markdown
+ - Code
+ - Json
+ - Button
+ - Link
+ - LinkList
+ - Navbar
+ - Modal
+ - ServerLoad
+ - Image
+ - Iframe
+ - FireEvent
+ - Error
+ - Spinner
+ - Toast
+ - Custom
+ - Table
+ - Pagination
+ - Display
+ - Details
+ - Form
+ - FormField
+ - ModelForm
+ - Footer
+ - AnyComponent
+ - FormFieldBoolean
+ - FormFieldFile
+ - FormFieldInput
+ - FormFieldSelect
+ - FormFieldSelectSearch
+
+
diff --git a/docs/api/typescript_components.md b/docs/api/typescript_components.md
new file mode 100644
index 00000000..4577df53
--- /dev/null
+++ b/docs/api/typescript_components.md
@@ -0,0 +1,9 @@
+# TypeScript Components
+
+!!! warning "🚧 Work in Progress"
+ This page is a work in progress.
+
+
+
diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png
new file mode 100644
index 00000000..dac6c476
Binary files /dev/null and b/docs/assets/favicon.png differ
diff --git a/docs/assets/logo-white.svg b/docs/assets/logo-white.svg
new file mode 100644
index 00000000..61cc5bdb
--- /dev/null
+++ b/docs/assets/logo-white.svg
@@ -0,0 +1,5 @@
+
diff --git a/docs/extra/tweaks.css b/docs/extra/tweaks.css
new file mode 100644
index 00000000..0f6f7dba
--- /dev/null
+++ b/docs/extra/tweaks.css
@@ -0,0 +1,5 @@
+/* Revert hue value to that of pre mkdocs-material v9.4.0 */
+[data-md-color-scheme='slate'] {
+ --md-hue: 230;
+ --md-default-bg-color: hsla(230, 15%, 21%, 1);
+}
diff --git a/docs/guide.md b/docs/guide.md
new file mode 100644
index 00000000..8a5c4ccd
--- /dev/null
+++ b/docs/guide.md
@@ -0,0 +1,2 @@
+!!! warning "🚧 Work in Progress"
+ This page is a work in progress.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 00000000..92b07c5a
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,141 @@
+# FastUI
+
+## The Principle
+
+FastUI is a new way to build web application user interfaces defined by declarative Python code.
+
+This means:
+
+- **If you're a Python developer** — you can build responsive web applications using React without writing a single line of JavaScript, or touching `npm`.
+- **If you're a frontend developer** — you can concentrate on building magical components that are truly reusable, no copy-pasting components for each view.
+- **For everyone** — a true separation of concerns, the backend defines the entire application; while the frontend is free to implement just the user interface
+
+At its heart, FastUI is a set of matching [Pydantic](https://docs.pydantic.dev) models and TypeScript interfaces that allow you to define a user interface. This interface is validated at build time by TypeScript and pyright/mypy and at runtime by Pydantic.
+
+You can see a simple demo of an application built with FastUI [here](https://fastui-demo.onrender.com).
+
+## The Practice - Installation
+
+FastUI is made up of 4 things:
+
+- [`fastui` PyPI package](https://pypi.python.org/pypi/fastui) — Pydantic models for UI components, and some utilities. While it works well with [FastAPI](https://fastapi.tiangolo.com) it doesn't depend on FastAPI, and most of it could be used with any python web framework.
+- [`@pydantic/fastui` npm package](https://www.npmjs.com/package/@pydantic/fastui) — a React TypeScript package that lets you reuse the machinery and types of FastUI while implementing your own components
+- [`@pydantic/fastui-bootstrap` npm package](https://www.npmjs.com/package/@pydantic/fastui-bootstrap) — implementation/customisation of all FastUI components using [Bootstrap](https://getbootstrap.com)
+- [`@pydantic/fastui-prebuilt` npm package](https://www.jsdelivr.com/package/npm/@pydantic/fastui-prebuilt) (available on [jsdelivr.com CDN](https://www.jsdelivr.com/package/npm/@pydantic/fastui-prebuilt)) providing a pre-built version of the FastUI React app so you can use it without installing any npm packages or building anything yourself. The Python package provides a simple HTML page to serve this app.
+
+## Usage
+
+Here's a simple but complete FastAPI application that uses FastUI to show some user profiles:
+
+```python
+from datetime import date
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import HTMLResponse
+from fastui import FastUI, AnyComponent, prebuilt_html, components as c
+from fastui.components.display import DisplayMode, DisplayLookup
+from fastui.events import GoToEvent, BackEvent
+from pydantic import BaseModel, Field
+
+app = FastAPI()
+
+
+class User(BaseModel):
+ id: int
+ name: str
+ dob: date = Field(title='Date of Birth')
+
+
+# define some users
+users = [
+ User(id=1, name='John', dob=date(1990, 1, 1)),
+ User(id=2, name='Jack', dob=date(1991, 1, 1)),
+ User(id=3, name='Jill', dob=date(1992, 1, 1)),
+ User(id=4, name='Jane', dob=date(1993, 1, 1)),
+]
+
+
+@app.get("/api/", response_model=FastUI, response_model_exclude_none=True)
+def users_table() -> list[AnyComponent]:
+ """
+ Show a table of four users, `/api` is the endpoint the frontend will connect to
+ when a user visits `/` to fetch components to render.
+ """
+ return [
+ c.Page( # Page provides a basic container for components
+ components=[
+ c.Heading(text='Users', level=2), # renders `Users
`
+ c.Table(
+ data=users,
+ # define two columns for the table
+ columns=[
+ # the first is the users, name rendered as a link to their profile
+ DisplayLookup(field='name', on_click=GoToEvent(url='/user/{id}/')),
+ # the second is the date of birth, rendered as a date
+ DisplayLookup(field='dob', mode=DisplayMode.date),
+ ],
+ ),
+ ]
+ ),
+ ]
+
+
+@app.get("/api/user/{user_id}/", response_model=FastUI, response_model_exclude_none=True)
+def user_profile(user_id: int) -> list[AnyComponent]:
+ """
+ User profile page, the frontend will fetch this when the user visits `/user/{id}/`.
+ """
+ try:
+ user = next(u for u in users if u.id == user_id)
+ except StopIteration:
+ raise HTTPException(status_code=404, detail="User not found")
+ return [
+ c.Page(
+ components=[
+ c.Heading(text=user.name, level=2),
+ c.Link(components=[c.Text(text='Back')], on_click=BackEvent()),
+ c.Details(data=user),
+ ]
+ ),
+ ]
+
+
+@app.get('/{path:path}')
+async def html_landing() -> HTMLResponse:
+ """Simple HTML page which serves the React app, comes last as it matches all paths."""
+ return HTMLResponse(prebuilt_html(title='FastUI Demo'))
+```
+
+Which renders like this:
+
+
+
+Of course, that's a very simple application, the [full demo](https://fastui-demo.onrender.com) is more complete.
+
+### The Principle (long version)
+
+FastUI is an implementation of the RESTful principle; but not as it's usually understood, instead I mean the principle defined in the original [PhD dissertation](https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) by Roy Fielding, and excellently summarised in [this essay on htmx.org](https://htmx.org/essays/how-did-rest-come-to-mean-the-opposite-of-rest/) (HTMX people, I'm sorry to use your article to promote React which I know you despise 🙏).
+
+The RESTful principle as described in the HTMX article is that the frontend doesn't need to (and shouldn't) know anything about the application you're building. Instead, it should just provide all the components you need to construct the interface, the backend can then tell the frontend what to do.
+
+Think of your frontend as a puppet, and the backend as the hand within it — the puppet doesn't need to know what to say, that's kind of the point.
+
+Building an application this way has a number of significant advantages:
+
+- You only need to write code in one place to build a new feature — add a new view, change the behavior of an existing view or alter the URL structure
+- Deploying the front and backend can be completely decoupled, provided the frontend knows how to render all the components the backend is going to ask it to use, you're good to go
+- You should be able to reuse a rich set of opensource components, they should end up being better tested and more reliable than anything you could build yourself, this is possible because the components need no context about how they're going to be used (note: since FastUI is brand new, this isn't true yet, hopefully we get there)
+- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema
+
+In the abstract, FastUI is like the opposite of GraphQL but with the same goal — GraphQL lets frontend developers extend an application without any new backend development; FastUI lets backend developers extend an application without any new frontend development.
+
+#### Beyond Python and React
+
+Of course, this principle shouldn't be limited to Python and React applications — provided we use the same set of agreed schemas and encoding to communicate, we should be able to use any frontend and backend that implements the schema. Interchangeably.
+
+This could mean:
+
+- Implementing a web frontend using another JS framework like Vue — lots of work, limited value
+- Implementing a web frontend using an edge server, so the browser just sees HTML — lots of work but very valuable
+- Implementing frontends for other platforms like mobile or IOT — lots of work, no idea if it's actually a good idea?
+- Implementing the component models in another language like Rust or Go — since there's actually not that much code in the backend, so this would be a relatively small and mechanical task
diff --git a/docs/plugins.py b/docs/plugins.py
new file mode 100644
index 00000000..06f99315
--- /dev/null
+++ b/docs/plugins.py
@@ -0,0 +1,74 @@
+import os
+import re
+
+from typing import Match
+
+from mkdocs.config import Config
+from mkdocs.structure.files import Files
+from mkdocs.structure.pages import Page
+
+try:
+ import pytest
+except ImportError:
+ pytest = None
+
+
+def on_pre_build(config: Config):
+ pass
+
+
+def on_files(files: Files, config: Config) -> Files:
+ return remove_files(files)
+
+
+def remove_files(files: Files) -> Files:
+ to_remove = []
+ for file in files:
+ if file.src_path in {'plugins.py'}:
+ to_remove.append(file)
+ elif file.src_path.startswith('__pycache__/'):
+ to_remove.append(file)
+
+ for f in to_remove:
+ files.remove(f)
+
+ return files
+
+
+def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str:
+ markdown = remove_code_fence_attributes(markdown)
+ return add_version(markdown, page)
+
+
+def add_version(markdown: str, page: Page) -> str:
+ if page.file.src_uri == 'index.md':
+ version_ref = os.getenv('GITHUB_REF')
+ if version_ref and version_ref.startswith('refs/tags/'):
+ version = re.sub('^refs/tags/', '', version_ref.lower())
+ url = f'https://github.com/pydantic/FastUI/releases/tag/{version}'
+ version_str = f'Documentation for version: [{version}]({url})'
+ elif sha := os.getenv('GITHUB_SHA'):
+ sha = sha[:7]
+ url = f'https://github.com/pydantic/FastUI/commit/{sha}'
+ version_str = f'Documentation for development version: [{sha}]({url})'
+ else:
+ version_str = 'Documentation for development version'
+ markdown = re.sub(r'{{ *version *}}', version_str, markdown)
+ return markdown
+
+
+def remove_code_fence_attributes(markdown: str) -> str:
+ """
+ There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use
+ `py key="value"` to provide attributes to pytest-examples, then remove those attributes here.
+
+ https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/
+ """
+
+ def remove_attrs(match: Match[str]) -> str:
+ suffix = re.sub(
+ r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M
+ )
+ return f'{match.group(1)}{suffix}'
+
+ return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M)
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 00000000..817d8382
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,93 @@
+site_name: FastUI
+site_description: Build web application user interfaces defined by declarative Python code.
+site_url: https://docs.pydantic.dev/fastui/
+
+theme:
+ name: 'material'
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: pink
+ accent: pink
+ toggle:
+ icon: material/lightbulb-outline
+ name: "Switch to dark mode"
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: pink
+ accent: pink
+ toggle:
+ icon: material/lightbulb
+ name: "Switch to light mode"
+ features:
+ - content.code.annotate
+ - content.tabs.link
+ - content.code.copy
+ - announce.dismiss
+ - navigation.tabs
+ - search.suggest
+ - search.highlight
+ logo: assets/logo-white.svg
+ favicon: assets/favicon.png
+
+repo_name: pydantic/FastUI
+repo_url: https://github.com/pydantic/FastUI
+edit_uri: ''
+
+# https://www.mkdocs.org/user-guide/configuration/#validation
+validation:
+ omitted_files: warn
+ absolute_links: warn
+ unrecognized_links: warn
+
+extra_css:
+ - 'extra/tweaks.css'
+
+# TODO: add flarelytics support
+# extra_javascript:
+# - '/flarelytics/client.js'
+
+markdown_extensions:
+ - toc:
+ permalink: true
+ - admonition
+ - pymdownx.details
+ - pymdownx.extra
+ - pymdownx.superfences
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
+ - attr_list
+ - md_in_html
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+watch:
+ - src
+plugins:
+ - search
+ - mkdocstrings:
+ handlers:
+ python:
+ paths:
+ - src/python-fastui
+ options:
+ members_order: source
+ separate_signature: true
+ docstring_options:
+ ignore_init_summary: true
+ merge_init_into_class: true
+ show_signature_annotations: true
+ signature_crossrefs: true
+ - mkdocs-simple-hooks:
+ hooks:
+ on_pre_build: 'docs.plugins:on_pre_build'
+ on_files: 'docs.plugins:on_files'
+ on_page_markdown: 'docs.plugins:on_page_markdown'
+nav:
+ - Introduction: index.md
+ - Guide: guide.md
+ - API Documentation:
+ - Python Components: api/python_components.md
+ - TypeScript Components: api/typescript_components.md
diff --git a/package-lock.json b/package-lock.json
index 3b287145..5cc21c61 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"workspaces": [
"src/*"
],
+ "dependencies": {
+ "prettier": "^3.2.5",
+ "typedoc": "^0.25.13"
+ },
"devDependencies": {
"@types/node": "^20.9.1",
"@types/react": "^18.2.15",
@@ -22,7 +26,6 @@
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"json-schema-to-typescript": "^13.1.1",
- "prettier": "^3.0.3",
"typescript": "^5.0.2"
}
},
@@ -829,6 +832,11 @@
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true
},
+ "node_modules/@microsoft/fetch-event-source": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz",
+ "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1710,6 +1718,11 @@
"node": ">=8"
}
},
+ "node_modules/ansi-sequence-parser": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz",
+ "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg=="
+ },
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -1927,8 +1940,7 @@
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/binary-extensions": {
"version": "2.2.0",
@@ -4227,6 +4239,11 @@
"json5": "lib/cli.js"
}
},
+ "node_modules/jsonc-parser": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz",
+ "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA=="
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4350,6 +4367,11 @@
"es5-ext": "~0.10.2"
}
},
+ "node_modules/lunr": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
+ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="
+ },
"node_modules/markdown-table": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz",
@@ -4359,6 +4381,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/marked": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
+ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz",
@@ -5554,10 +5587,9 @@
}
},
"node_modules/prettier": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
- "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
- "dev": true,
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
+ "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -6141,6 +6173,17 @@
"node": ">=8"
}
},
+ "node_modules/shiki": {
+ "version": "0.14.7",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz",
+ "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==",
+ "dependencies": {
+ "ansi-sequence-parser": "^1.1.0",
+ "jsonc-parser": "^3.2.0",
+ "vscode-oniguruma": "^1.7.0",
+ "vscode-textmate": "^8.0.0"
+ }
+ },
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -6523,11 +6566,52 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typedoc": {
+ "version": "0.25.13",
+ "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz",
+ "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==",
+ "dependencies": {
+ "lunr": "^2.3.9",
+ "marked": "^4.3.0",
+ "minimatch": "^9.0.3",
+ "shiki": "^0.14.7"
+ },
+ "bin": {
+ "typedoc": "bin/typedoc"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "peerDependencies": {
+ "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x"
+ }
+ },
+ "node_modules/typedoc/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/typedoc/node_modules/minimatch": {
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
+ "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
- "dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6702,14 +6786,13 @@
}
},
"node_modules/vite": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.0.tgz",
- "integrity": "sha512-ESJVM59mdyGpsiNAeHQOR/0fqNoOyWPYesFto8FFZugfmhdHx8Fzd8sF3Q/xkVhZsyOxHfdM7ieiVAorI9RjFw==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz",
+ "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.19.3",
- "postcss": "^8.4.31",
+ "postcss": "^8.4.32",
"rollup": "^4.2.0"
},
"bin": {
@@ -6757,6 +6840,16 @@
}
}
},
+ "node_modules/vscode-oniguruma": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
+ "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA=="
+ },
+ "node_modules/vscode-textmate": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz",
+ "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg=="
+ },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
@@ -6950,9 +7043,10 @@
},
"src/npm-fastui": {
"name": "@pydantic/fastui",
- "version": "0.0.14",
+ "version": "0.0.22",
"license": "MIT",
"dependencies": {
+ "@microsoft/fetch-event-source": "^2.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
@@ -6966,7 +7060,7 @@
},
"src/npm-fastui-bootstrap": {
"name": "@pydantic/fastui-bootstrap",
- "version": "0.0.14",
+ "version": "0.0.22",
"license": "MIT",
"dependencies": {
"bootstrap": "^5.3.2",
@@ -6976,71 +7070,16 @@
"sass": "^1.69.5"
},
"peerDependencies": {
- "@pydantic/fastui": "0.0.14"
+ "@pydantic/fastui": "0.0.22"
}
},
"src/npm-fastui-prebuilt": {
"name": "@pydantic/fastui-prebuilt",
- "version": "0.0.14",
+ "version": "0.0.22",
"license": "MIT",
"devDependencies": {
"@vitejs/plugin-react-swc": "^3.3.2",
- "vite": "^5.0.7"
- }
- },
- "src/npm-fastui-prebuilt/node_modules/vite": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.7.tgz",
- "integrity": "sha512-B4T4rJCDPihrQo2B+h1MbeGL/k/GMAHzhQ8S0LjQ142s6/+l3hHTT095ORvsshj4QCkoWu3Xtmob5mazvakaOw==",
- "dev": true,
- "dependencies": {
- "esbuild": "^0.19.3",
- "postcss": "^8.4.32",
- "rollup": "^4.2.0"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- }
+ "vite": "^5.0.12"
}
}
}
diff --git a/package.json b/package.json
index 98639d0a..6036751c 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,10 @@
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"json-schema-to-typescript": "^13.1.1",
- "prettier": "^3.0.3",
"typescript": "^5.0.2"
+ },
+ "dependencies": {
+ "prettier": "^3.2.5",
+ "typedoc": "^0.25.13"
}
}
diff --git a/pyproject.toml b/pyproject.toml
index cae01196..d4518502 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,3 +26,15 @@ omit = [
"src/python-fastui/fastui/__main__.py",
"src/python-fastui/fastui/generate_typescript.py",
]
+
+[tool.coverage.report]
+precision = 2
+exclude_lines = [
+ 'pragma: no cover',
+ 'raise NotImplementedError',
+ 'if TYPE_CHECKING:',
+ 'if typing.TYPE_CHECKING:',
+ '@overload',
+ '@typing.overload',
+ '\(Protocol\):$',
+]
diff --git a/requirements/docs.in b/requirements/docs.in
new file mode 100644
index 00000000..fee98130
--- /dev/null
+++ b/requirements/docs.in
@@ -0,0 +1,6 @@
+mkdocs
+mkdocs-material
+mkdocs-simple-hooks
+mkdocstrings[python]
+mkdocs-redirects
+mkdocs-material-extensions
diff --git a/requirements/docs.txt b/requirements/docs.txt
new file mode 100644
index 00000000..b9e3ca66
--- /dev/null
+++ b/requirements/docs.txt
@@ -0,0 +1,107 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+# pip-compile --output-file=requirements/docs.txt requirements/docs.in
+#
+babel==2.14.0
+ # via mkdocs-material
+certifi==2024.2.2
+ # via requests
+charset-normalizer==3.3.2
+ # via requests
+click==8.1.7
+ # via
+ # mkdocs
+ # mkdocstrings
+colorama==0.4.6
+ # via
+ # griffe
+ # mkdocs-material
+ghp-import==2.1.0
+ # via mkdocs
+griffe==0.44.0
+ # via mkdocstrings-python
+idna==3.7
+ # via requests
+jinja2==3.1.3
+ # via
+ # mkdocs
+ # mkdocs-material
+ # mkdocstrings
+markdown==3.6
+ # via
+ # mkdocs
+ # mkdocs-autorefs
+ # mkdocs-material
+ # mkdocstrings
+ # pymdown-extensions
+markupsafe==2.1.5
+ # via
+ # jinja2
+ # mkdocs
+ # mkdocs-autorefs
+ # mkdocstrings
+mergedeep==1.3.4
+ # via mkdocs
+mkdocs==1.5.3
+ # via
+ # -r requirements/docs.in
+ # mkdocs-autorefs
+ # mkdocs-material
+ # mkdocs-redirects
+ # mkdocs-simple-hooks
+ # mkdocstrings
+mkdocs-autorefs==1.0.1
+ # via mkdocstrings
+mkdocs-material==9.5.18
+ # via -r requirements/docs.in
+mkdocs-material-extensions==1.3.1
+ # via
+ # -r requirements/docs.in
+ # mkdocs-material
+mkdocs-redirects==1.2.1
+ # via -r requirements/docs.in
+mkdocs-simple-hooks==0.1.5
+ # via -r requirements/docs.in
+mkdocstrings[python]==0.24.3
+ # via
+ # -r requirements/docs.in
+ # mkdocstrings-python
+mkdocstrings-python==1.10.0
+ # via mkdocstrings
+packaging==24.0
+ # via mkdocs
+paginate==0.5.6
+ # via mkdocs-material
+pathspec==0.12.1
+ # via mkdocs
+platformdirs==4.2.0
+ # via
+ # mkdocs
+ # mkdocstrings
+pygments==2.17.2
+ # via mkdocs-material
+pymdown-extensions==10.8
+ # via
+ # mkdocs-material
+ # mkdocstrings
+python-dateutil==2.9.0.post0
+ # via ghp-import
+pyyaml==6.0.1
+ # via
+ # mkdocs
+ # pymdown-extensions
+ # pyyaml-env-tag
+pyyaml-env-tag==0.1
+ # via mkdocs
+regex==2024.4.16
+ # via mkdocs-material
+requests==2.31.0
+ # via mkdocs-material
+six==1.16.0
+ # via python-dateutil
+urllib3==2.2.1
+ # via requests
+watchdog==4.0.0
+ # via mkdocs
diff --git a/src/npm-fastui-bootstrap/LICENSE b/src/npm-fastui-bootstrap/LICENSE
index 286f4f19..e93c72cf 100644
--- a/src/npm-fastui-bootstrap/LICENSE
+++ b/src/npm-fastui-bootstrap/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2023 to present Samuel Colvin
+Copyright (c) 2023 to present Pydantic Services inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json
index ed396916..c232b538 100644
--- a/src/npm-fastui-bootstrap/package.json
+++ b/src/npm-fastui-bootstrap/package.json
@@ -1,7 +1,7 @@
{
"name": "@pydantic/fastui-bootstrap",
- "version": "0.0.14",
- "description": "Boostrap renderer for FastUI",
+ "version": "0.0.24",
+ "description": "Bootstrap renderer for FastUI",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"author": "Samuel Colvin",
@@ -29,6 +29,6 @@
"sass": "^1.69.5"
},
"peerDependencies": {
- "@pydantic/fastui": "0.0.14"
+ "@pydantic/fastui": "0.0.24"
}
}
diff --git a/src/npm-fastui-bootstrap/src/footer.tsx b/src/npm-fastui-bootstrap/src/footer.tsx
new file mode 100644
index 00000000..d3d251c3
--- /dev/null
+++ b/src/npm-fastui-bootstrap/src/footer.tsx
@@ -0,0 +1,22 @@
+import { FC } from 'react'
+import { components, models, useClassName } from 'fastui'
+
+export const Footer: FC = (props) => {
+ const links = props.links.map((link) => {
+ link.mode = link.mode || 'footer'
+ return link
+ })
+ const extraProp = useClassName(props, { el: 'extra' })
+ return (
+
+ )
+}
diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx
index da7aa690..46dfe9a8 100644
--- a/src/npm-fastui-bootstrap/src/index.tsx
+++ b/src/npm-fastui-bootstrap/src/index.tsx
@@ -6,18 +6,24 @@ import { Modal } from './modal'
import { Navbar } from './navbar'
import { Pagination } from './pagination'
import { DarkMode } from './DarkMode'
+import { Footer } from './footer'
+import { Toast } from './toast'
export const customRender: CustomRender = (props) => {
const { type } = props
switch (type) {
case 'Navbar':
return () =>
+ case 'Footer':
+ return () =>
case 'Modal':
return () =>
case 'Pagination':
return () =>
case 'DarkMode':
return () =>
+ case 'Toast':
+ return () =>
}
}
@@ -29,9 +35,14 @@ export const classNameGenerator: ClassNameGenerator = ({
const { type } = props
switch (type) {
case 'Page':
- return 'container mt-80'
+ return 'container mt-80 mb-3 page'
case 'Button':
- return 'btn btn-primary'
+ return {
+ btn: true,
+ 'btn-primary': !props.namedStyle || props.namedStyle === 'primary',
+ 'btn-secondary': props.namedStyle === 'secondary',
+ 'btn-warning': props.namedStyle === 'warning',
+ }
case 'Table':
switch (subElement) {
case 'no-data-message':
@@ -57,20 +68,24 @@ export const classNameGenerator: ClassNameGenerator = ({
default:
return 'row row-cols-lg-4 align-items-center justify-content-end'
}
- } else {
+ } else if (props.displayMode === 'page') {
switch (subElement) {
case 'form-container':
return 'row justify-content-center'
default:
return 'col-md-4'
}
+ } else {
+ break
}
case 'FormFieldInput':
+ case 'FormFieldTextarea':
case 'FormFieldBoolean':
case 'FormFieldSelect':
case 'FormFieldSelectSearch':
case 'FormFieldFile':
switch (subElement) {
+ case 'textarea':
case 'input':
return {
'form-control': type !== 'FormFieldBoolean',
@@ -107,10 +122,20 @@ export const classNameGenerator: ClassNameGenerator = ({
default:
return 'border-bottom fixed-top bg-body'
}
+ case 'Footer':
+ switch (subElement) {
+ case 'link-list':
+ return 'nav justify-content-center pb-1'
+ case 'extra':
+ return 'text-center text-muted pb-3'
+ default:
+ return 'border-top pt-1 mt-auto bg-body'
+ }
case 'Link':
return {
active: pathMatch(props.active, fullPath),
- 'nav-link': props.mode === 'navbar' || props.mode === 'tabs',
+ 'nav-link': props.mode === 'navbar' || props.mode === 'tabs' || props.mode === 'footer',
+ 'text-muted': props.mode === 'footer',
}
case 'LinkList':
if (subElement === 'link-list-item' && props.mode) {
@@ -127,5 +152,19 @@ export const classNameGenerator: ClassNameGenerator = ({
}
case 'Code':
return 'rounded'
+ case 'Error':
+ if (props.statusCode === 502) {
+ return 'm-3 text-muted'
+ } else {
+ return 'error-alert alert alert-danger m-3'
+ }
+ case 'Spinner':
+ if (subElement === 'text') {
+ return 'd-flex justify-content-center mb-2'
+ } else if (subElement === 'animation') {
+ return 'd-flex justify-content-center'
+ } else {
+ return 'my-4'
+ }
}
}
diff --git a/src/npm-fastui-bootstrap/src/navbar.tsx b/src/npm-fastui-bootstrap/src/navbar.tsx
index 4e84faf6..3f5adec8 100644
--- a/src/npm-fastui-bootstrap/src/navbar.tsx
+++ b/src/npm-fastui-bootstrap/src/navbar.tsx
@@ -3,7 +3,11 @@ import { components, useClassName, models } from 'fastui'
import BootstrapNavbar from 'react-bootstrap/Navbar'
export const Navbar: FC = (props) => {
- const links = props.links.map((link) => {
+ const startLinks = props.startLinks.map((link) => {
+ link.mode = link.mode || 'navbar'
+ return link
+ })
+ const endLinks = props.endLinks.map((link) => {
link.mode = link.mode || 'navbar'
return link
})
@@ -14,7 +18,14 @@ export const Navbar: FC = (props) => {
- {links.map((link, i) => (
+ {startLinks.map((link, i) => (
+ -
+
+
+ ))}
+
+
+ {endLinks.map((link, i) => (
-
diff --git a/src/npm-fastui-bootstrap/src/pagination.tsx b/src/npm-fastui-bootstrap/src/pagination.tsx
index ed32fe68..d57adb07 100644
--- a/src/npm-fastui-bootstrap/src/pagination.tsx
+++ b/src/npm-fastui-bootstrap/src/pagination.tsx
@@ -7,11 +7,11 @@ interface Link {
locked?: boolean
active?: boolean
page?: number
+ pageQueryParam?: string
}
export const Pagination: FC = (props) => {
- const { page, pageCount } = props
-
+ const { page, pageCount, pageQueryParam } = props
if (pageCount === 1) return null
const links: Link[] = [
@@ -20,17 +20,19 @@ export const Pagination: FC = (props) => {
ariaLabel: 'Previous',
locked: page === 1,
page: page - 1,
+ pageQueryParam,
},
{
Display: () => <>1>,
locked: page === 1,
active: page === 1,
page: 1,
+ pageQueryParam,
},
]
if (page > 4) {
- links.push({ Display: () => <>...> })
+ links.push({ Display: () => <>...>, pageQueryParam })
}
for (let p = page - 2; p <= page + 2; p++) {
@@ -40,17 +42,19 @@ export const Pagination: FC = (props) => {
locked: page === p,
active: page === p,
page: p,
+ pageQueryParam,
})
}
if (page < pageCount - 3) {
- links.push({ Display: () => <>...> })
+ links.push({ Display: () => <>...>, pageQueryParam })
}
links.push({
Display: () => <>{pageCount}>,
locked: page === pageCount,
page: pageCount,
+ pageQueryParam,
})
links.push({
@@ -58,6 +62,7 @@ export const Pagination: FC = (props) => {
ariaLabel: 'Next',
locked: page === pageCount,
page: page + 1,
+ pageQueryParam,
})
return (
@@ -71,7 +76,7 @@ export const Pagination: FC = (props) => {
)
}
-const PaginationLink: FC = ({ Display, ariaLabel, locked, active, page }) => {
+const PaginationLink: FC = ({ Display, ariaLabel, locked, active, page, pageQueryParam }) => {
if (!page) {
return (
-
@@ -82,11 +87,9 @@ const PaginationLink: FC = ({ Display, ariaLabel, locked, active, page })
)
}
const className = renderClassName({ 'page-link': true, disabled: locked && !active, active } as models.ClassName)
- let onClick: models.GoToEvent
- if (page === 1) {
- onClick = { type: 'go-to', query: {} }
- } else {
- onClick = { type: 'go-to', query: { page } }
+ const onClick: models.GoToEvent = {
+ type: 'go-to',
+ query: { [pageQueryParam !== undefined ? pageQueryParam : 'page']: page },
}
return (
-
diff --git a/src/npm-fastui-bootstrap/src/toast.tsx b/src/npm-fastui-bootstrap/src/toast.tsx
new file mode 100644
index 00000000..989d6949
--- /dev/null
+++ b/src/npm-fastui-bootstrap/src/toast.tsx
@@ -0,0 +1,25 @@
+import { FC } from 'react'
+import { components, events, renderClassName, EventContextProvider, models } from 'fastui'
+import BootstrapToast from 'react-bootstrap/Toast'
+import BootstrapToastContainer from 'react-bootstrap/ToastContainer'
+
+export const Toast: FC = (props) => {
+ const { className, title, body, position, openTrigger, openContext } = props
+
+ const { eventContext, fireId, clear } = events.usePageEventListen(openTrigger, openContext)
+
+ return (
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/npm-fastui-bootstrap/typedoc.json b/src/npm-fastui-bootstrap/typedoc.json
new file mode 100644
index 00000000..4d479e43
--- /dev/null
+++ b/src/npm-fastui-bootstrap/typedoc.json
@@ -0,0 +1,5 @@
+{
+ "extends": ["../../typedoc.base.json"],
+ "entryPointStrategy": "expand",
+ "entryPoints": ["src"]
+}
diff --git a/src/npm-fastui-prebuilt/LICENSE b/src/npm-fastui-prebuilt/LICENSE
index 286f4f19..e93c72cf 100644
--- a/src/npm-fastui-prebuilt/LICENSE
+++ b/src/npm-fastui-prebuilt/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2023 to present Samuel Colvin
+Copyright (c) 2023 to present Pydantic Services inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json
index bece3aef..d343c1a8 100644
--- a/src/npm-fastui-prebuilt/package.json
+++ b/src/npm-fastui-prebuilt/package.json
@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui-prebuilt",
- "version": "0.0.14",
+ "version": "0.0.24",
"description": "Pre-built files for FastUI",
"main": "dist/index.html",
"type": "module",
@@ -23,6 +23,6 @@
},
"devDependencies": {
"@vitejs/plugin-react-swc": "^3.3.2",
- "vite": "^5.0.7"
+ "vite": "^5.0.12"
}
}
diff --git a/src/npm-fastui-prebuilt/src/App.tsx b/src/npm-fastui-prebuilt/src/App.tsx
index 4f5f42b8..c43cfdd4 100644
--- a/src/npm-fastui-prebuilt/src/App.tsx
+++ b/src/npm-fastui-prebuilt/src/App.tsx
@@ -4,19 +4,22 @@ import { FC, ReactNode } from 'react'
export default function App() {
return (
-
-
-
+
)
}
+function getMetaContent(name: string): string | undefined {
+ return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') || undefined
+}
+
const NotFound = ({ url }: { url: string }) => (
Page not found
@@ -26,17 +29,11 @@ const NotFound = ({ url }: { url: string }) => (
)
-const Spinner = () => (
-
-)
-
const Transition: FC<{ children: ReactNode; transitioning: boolean }> = ({ children, transitioning }) => (
-
+ >
)
const customRender: CustomRender = (props) => {
diff --git a/src/npm-fastui-prebuilt/src/main.scss b/src/npm-fastui-prebuilt/src/main.scss
index d485a334..98e27566 100644
--- a/src/npm-fastui-prebuilt/src/main.scss
+++ b/src/npm-fastui-prebuilt/src/main.scss
@@ -1,8 +1,25 @@
$primary: black;
+$secondary: white;
$link-color: #0d6efd; // bootstrap primary
@import 'bootstrap/scss/bootstrap';
+html,
+body,
+#root {
+ height: 100%;
+}
+
+#root {
+ display: flex;
+ flex-direction: column;
+}
+
+.page {
+ // margin-top because the top is sticky
+ margin-top: 70px;
+}
+
:root {
--bs-font-sans-serif: 'IBM Plex Sans', sans-serif;
--bs-code-color: rgb(31, 35, 40);
@@ -19,16 +36,13 @@ body {
backdrop-filter: blur(8px);
}
-.top-offset {
- margin-top: 70px;
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- scroll-margin-top: 60px;
- }
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ scroll-margin-top: 60px;
}
.transition-overlay {
@@ -60,17 +74,17 @@ body {
}
// custom spinner from https://cssloaders.github.io/
-
-.spinner,
-.spinner:before,
-.spinner:after {
+.fastui-spinner-animation,
+.fastui-spinner-animation:before,
+.fastui-spinner-animation:after {
border-radius: 50%;
width: 2.5em;
height: 2.5em;
animation-fill-mode: both;
- animation: dots 1.8s infinite ease-in-out;
+ animation: spinner-dots 1.8s infinite ease-in-out;
}
-.spinner {
+.fastui-spinner-animation {
+ top: -2.5em;
color: var(--bs-dark);
font-size: 7px;
position: relative;
@@ -91,8 +105,7 @@ body {
left: 3.5em;
}
}
-
-@keyframes dots {
+@keyframes spinner-dots {
0%,
80%,
100% {
@@ -102,3 +115,13 @@ body {
box-shadow: 0 2.5em 0 0;
}
}
+
+// make sure alerts aren't hidden behind the navbar
+.error-alert {
+ position: relative;
+ top: 60px;
+}
+
+.btn-secondary {
+ --bs-btn-border-color: #dee2e6;
+}
diff --git a/src/npm-fastui-prebuilt/typedoc.json b/src/npm-fastui-prebuilt/typedoc.json
new file mode 100644
index 00000000..4d479e43
--- /dev/null
+++ b/src/npm-fastui-prebuilt/typedoc.json
@@ -0,0 +1,5 @@
+{
+ "extends": ["../../typedoc.base.json"],
+ "entryPointStrategy": "expand",
+ "entryPoints": ["src"]
+}
diff --git a/src/npm-fastui-prebuilt/vite.config.ts b/src/npm-fastui-prebuilt/vite.config.ts
index c73c49a1..ee73325c 100644
--- a/src/npm-fastui-prebuilt/vite.config.ts
+++ b/src/npm-fastui-prebuilt/vite.config.ts
@@ -1,14 +1,25 @@
import path from 'path'
import react from '@vitejs/plugin-react-swc'
-import { defineConfig } from 'vite'
+import { defineConfig, HttpProxy } from 'vite'
export default () => {
const serverConfig = {
host: true,
port: 3000,
proxy: {
- '/api': 'http://localhost:8000',
+ '/api': {
+ target: 'http://localhost:8000',
+ configure: (proxy: HttpProxy.Server) => {
+ proxy.on('error', (err, _, res) => {
+ const { code } = err as any
+ if (code === 'ECONNREFUSED') {
+ res.writeHead(502, { 'content-type': 'text/plain' })
+ res.end('vite-proxy: Proxy connection refused')
+ }
+ })
+ },
+ },
},
}
diff --git a/src/npm-fastui/LICENSE b/src/npm-fastui/LICENSE
index 286f4f19..e93c72cf 100644
--- a/src/npm-fastui/LICENSE
+++ b/src/npm-fastui/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2023 to present Samuel Colvin
+Copyright (c) 2023 to present Pydantic Services inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json
index fe77ac90..b278341d 100644
--- a/src/npm-fastui/package.json
+++ b/src/npm-fastui/package.json
@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui",
- "version": "0.0.14",
+ "version": "0.0.24",
"description": "Build better UIs faster.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -21,6 +21,7 @@
"typewatch": "tsc --noEmit --watch"
},
"dependencies": {
+ "@microsoft/fetch-event-source": "^2.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
diff --git a/src/npm-fastui/src/Defaults.tsx b/src/npm-fastui/src/Defaults.tsx
index 3b636da5..a8469a61 100644
--- a/src/npm-fastui/src/Defaults.tsx
+++ b/src/npm-fastui/src/Defaults.tsx
@@ -1,7 +1,5 @@
import { FC, ReactNode } from 'react'
-export const DefaultSpinner: FC = () => loading...
-
export const DefaultNotFound: FC<{ url: string }> = ({ url }) => Page not found: {url}
// default here does nothing
diff --git a/src/npm-fastui/src/components/CodeLazy.tsx b/src/npm-fastui/src/components/CodeLazy.tsx
index a2f4fdee..123a35d7 100644
--- a/src/npm-fastui/src/components/CodeLazy.tsx
+++ b/src/npm-fastui/src/components/CodeLazy.tsx
@@ -10,12 +10,8 @@ export default function (props: Code) {
const codeLookup = codeStyle as keyof typeof codeStyles
const style = (codeStyle && codeStyles[codeLookup]) || codeStyles.coldarkCold
return (
-
+
+ {text}
+
)
}
diff --git a/src/npm-fastui/src/components/Custom.tsx b/src/npm-fastui/src/components/Custom.tsx
index 8ef1fb5f..f875fbaf 100644
--- a/src/npm-fastui/src/components/Custom.tsx
+++ b/src/npm-fastui/src/components/Custom.tsx
@@ -1,14 +1,13 @@
-import { FC, useContext } from 'react'
+import { FC } from 'react'
import type { Custom } from '../models'
-import { ErrorContext } from '../hooks/error'
+import { DisplayError } from '../hooks/error'
import { JsonComp } from './Json'
export const CustomComp: FC = (props) => {
const { data, subType, library } = props
- const { DisplayError } = useContext(ErrorContext)
const description = [`The custom component "${subType}"`]
if (library) {
diff --git a/src/npm-fastui/src/components/FireEvent.tsx b/src/npm-fastui/src/components/FireEvent.tsx
index 5949c122..7b4aa23c 100644
--- a/src/npm-fastui/src/components/FireEvent.tsx
+++ b/src/npm-fastui/src/components/FireEvent.tsx
@@ -1,4 +1,4 @@
-import { FC, useEffect, useRef } from 'react'
+import { FC, useEffect } from 'react'
import type { FireEvent } from '../models'
@@ -6,15 +6,12 @@ import { useFireEvent } from '../events'
export const FireEventComp: FC = ({ event, message }) => {
const { fireEvent } = useFireEvent()
- const fireEventRef = useRef(fireEvent)
useEffect(() => {
- fireEventRef.current = fireEvent
- }, [fireEvent])
-
- useEffect(() => {
- fireEventRef.current(event)
- }, [event, fireEventRef])
+ // debounce the event so changes to fireEvent (from location changes) don't trigger the event many times
+ const clear = setTimeout(() => fireEvent(event), 50)
+ return () => clearTimeout(clear)
+ }, [fireEvent, event])
return <>{message}>
}
diff --git a/src/npm-fastui/src/components/FormField.tsx b/src/npm-fastui/src/components/FormField.tsx
index 31eeca50..23614570 100644
--- a/src/npm-fastui/src/components/FormField.tsx
+++ b/src/npm-fastui/src/components/FormField.tsx
@@ -4,6 +4,7 @@ import Select, { StylesConfig } from 'react-select'
import type {
FormFieldInput,
+ FormFieldTextarea,
FormFieldBoolean,
FormFieldFile,
FormFieldSelect,
@@ -23,7 +24,7 @@ interface FormFieldInputProps extends FormFieldInput {
}
export const FormFieldInputComp: FC = (props) => {
- const { name, placeholder, required, htmlType, locked } = props
+ const { name, placeholder, required, htmlType, locked, autocomplete, onChange } = props
return (
@@ -37,7 +38,36 @@ export const FormFieldInputComp: FC = (props) => {
required={required}
disabled={locked}
placeholder={placeholder}
+ autoComplete={autocomplete}
aria-describedby={descId(props)}
+ onChange={onChange}
+ />
+
+
+ )
+}
+
+interface FormFieldTextareaProps extends FormFieldTextarea {
+ onChange?: PrivateOnChange
+}
+
+export const FormFieldTextareaComp: FC = (props) => {
+ const { name, placeholder, required, locked, rows, cols, autocomplete } = props
+ return (
+
+
+
@@ -49,7 +79,7 @@ interface FormFieldBooleanProps extends FormFieldBoolean {
}
export const FormFieldBooleanComp: FC = (props) => {
- const { name, required, locked } = props
+ const { name, required, locked, onChange } = props
return (
@@ -63,6 +93,7 @@ export const FormFieldBooleanComp: FC = (props) => {
required={required}
disabled={locked}
aria-describedby={descId(props)}
+ onChange={onChange}
/>
@@ -113,7 +144,7 @@ export const FormFieldSelectComp: FC = (props) => {
}
export const FormFieldSelectVanillaComp: FC = (props) => {
- const { name, required, locked, options, multiple, initial, placeholder, onChange } = props
+ const { name, required, locked, options, multiple, initial, placeholder, onChange, autocomplete } = props
const className = useClassName(props)
const classNameSelect = useClassName(props, { el: 'select' })
@@ -131,6 +162,7 @@ export const FormFieldSelectVanillaComp: FC = (props) => {
aria-describedby={descId(props)}
placeholder={placeholder}
onChange={() => onChange && onChange()}
+ autoComplete={autocomplete}
>
{multiple ? null : }
{options.map((option, i) => (
@@ -147,6 +179,12 @@ export const FormFieldSelectReactComp: FC = (props) => {
const className = useClassName(props)
const classNameSelectReact = useClassName(props, { el: 'select-react' })
+ let value
+ if (Array.isArray(initial)) {
+ value = findDefaultArray(options, initial)
+ } else {
+ value = findDefault(options, initial)
+ }
const reactSelectOnChanged = () => {
// TODO this is a hack to wait for the input to be updated, can we do better?
@@ -164,7 +202,7 @@ export const FormFieldSelectReactComp: FC = (props) => {
className={classNameSelectReact}
isMulti={multiple ?? false}
isClearable
- defaultValue={findDefault(options, initial)}
+ defaultValue={value}
name={name}
required={required}
isDisabled={locked}
@@ -193,6 +231,11 @@ const SelectOptionComp: FC<{ option: SelectOption | SelectGroup }> = ({ option }
}
}
+function findDefaultArray(options: SelectOptions, value: string[]): SelectOption[] {
+ const foundValues = value.map((v) => findDefault(options, v))
+ return foundValues.filter((v) => v) as SelectOption[]
+}
+
function findDefault(options: SelectOptions, value?: string): SelectOption | undefined {
for (const option of options) {
if ('options' in option) {
@@ -284,6 +327,7 @@ const Label: FC = (props) => {
export type FormFieldProps =
| FormFieldInputProps
+ | FormFieldTextareaProps
| FormFieldBooleanProps
| FormFieldFileProps
| FormFieldSelectProps
diff --git a/src/npm-fastui/src/components/Iframe.tsx b/src/npm-fastui/src/components/Iframe.tsx
index 818d8160..ab7a5ce5 100644
--- a/src/npm-fastui/src/components/Iframe.tsx
+++ b/src/npm-fastui/src/components/Iframe.tsx
@@ -3,6 +3,6 @@ import { FC } from 'react'
import type { Iframe } from '../models'
export const IframeComp: FC