├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── LICENSE ├── Makefile ├── README.md ├── build-docs.sh ├── bump_npm.py ├── demo ├── README.md ├── __init__.py ├── auth.py ├── auth_user.py ├── cities.json ├── components_list.py ├── forms.py ├── main.py ├── shared.py ├── sse.py ├── tables.py └── tests.py ├── docs ├── api │ ├── python_components.md │ └── typescript_components.md ├── assets │ ├── favicon.png │ └── logo-white.svg ├── extra │ └── tweaks.css ├── guide.md ├── index.md └── plugins.py ├── mkdocs.yml ├── package-lock.json ├── package.json ├── pyproject.toml ├── requirements ├── docs.in └── docs.txt ├── screenshot.png ├── src ├── npm-fastui-bootstrap │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── footer.tsx │ │ ├── index.tsx │ │ ├── modal.tsx │ │ ├── navbar.tsx │ │ ├── pagination.tsx │ │ └── toast.tsx │ ├── tsconfig.json │ └── typedoc.json ├── npm-fastui-prebuilt │ ├── LICENSE │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src │ │ ├── App.tsx │ │ ├── main.scss │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── typedoc.json │ └── vite.config.ts ├── npm-fastui │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Defaults.tsx │ │ ├── components │ │ │ ├── Code.tsx │ │ │ ├── CodeLazy.tsx │ │ │ ├── Custom.tsx │ │ │ ├── FireEvent.tsx │ │ │ ├── FormField.tsx │ │ │ ├── Iframe.tsx │ │ │ ├── Json.tsx │ │ │ ├── LinkList.tsx │ │ │ ├── Markdown.tsx │ │ │ ├── MarkdownLazy.tsx │ │ │ ├── PageTitle.tsx │ │ │ ├── ServerLoad.tsx │ │ │ ├── button.tsx │ │ │ ├── details.tsx │ │ │ ├── display.tsx │ │ │ ├── div.tsx │ │ │ ├── error.tsx │ │ │ ├── footer.tsx │ │ │ ├── form.tsx │ │ │ ├── heading.tsx │ │ │ ├── image.tsx │ │ │ ├── index.tsx │ │ │ ├── link.tsx │ │ │ ├── modal.tsx │ │ │ ├── navbar.tsx │ │ │ ├── pagination.tsx │ │ │ ├── paragraph.tsx │ │ │ ├── spinner.tsx │ │ │ ├── table.tsx │ │ │ ├── text.tsx │ │ │ ├── toast.tsx │ │ │ └── video.tsx │ │ ├── controller.tsx │ │ ├── dev.tsx │ │ ├── events.ts │ │ ├── hooks │ │ │ ├── className.ts │ │ │ ├── config.ts │ │ │ ├── error.tsx │ │ │ ├── eventContext.tsx │ │ │ └── locationContext.tsx │ │ ├── index.tsx │ │ ├── models.d.ts │ │ └── tools.ts │ ├── tsconfig.json │ └── typedoc.json └── python-fastui │ ├── LICENSE │ ├── README.md │ ├── fastui │ ├── __init__.py │ ├── __main__.py │ ├── auth │ │ ├── __init__.py │ │ ├── github.py │ │ └── shared.py │ ├── base.py │ ├── class_name.py │ ├── components │ │ ├── __init__.py │ │ ├── display.py │ │ ├── forms.py │ │ ├── py.typed │ │ └── tables.py │ ├── dev.py │ ├── events.py │ ├── forms.py │ ├── generate_typescript.py │ ├── json_schema.py │ ├── py.typed │ └── types.py │ ├── pyproject.toml │ ├── requirements │ ├── all.txt │ ├── lint.in │ ├── lint.txt │ ├── pyproject.txt │ ├── render.txt │ ├── test.in │ └── test.txt │ └── tests │ ├── test_auth_github.py │ ├── test_auth_shared.py │ ├── test_components.py │ ├── test_dev.py │ ├── test_forms.py │ ├── test_json_schema.py │ ├── test_prebuilt_html.py │ └── test_tables_display.py ├── tsconfig.json ├── typedoc.base.json └── typedoc.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'standard', 6 | 'eslint:recommended', 7 | 'plugin:react/recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:react-hooks/recommended', 10 | 'prettier', 11 | ], 12 | ignorePatterns: ['node_modules', 'dist', 'htmlcov'], 13 | parser: '@typescript-eslint/parser', 14 | plugins: ['react', '@typescript-eslint', 'react-refresh', 'simple-import-sort'], 15 | rules: { 16 | 'react-refresh/only-export-components': 'off', // how much effect does this have? 17 | '@typescript-eslint/no-explicit-any': 'off', 18 | 'no-use-before-define': 'off', 19 | 'react/react-in-jsx-scope': 'off', 20 | 'react/prop-types': 'off', 21 | 'react/display-name': 'off', 22 | 'import/order': [ 23 | 'error', 24 | { 25 | 'newlines-between': 'always', 26 | groups: ['builtin', 'external', 'internal', 'object', 'type', 'parent', 'index', 'sibling'], 27 | pathGroups: [ 28 | { 29 | pattern: '@/**', 30 | group: 'internal', 31 | }, 32 | { 33 | pattern: './../**', 34 | group: 'parent', 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: monthly 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.11' 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: 18 26 | 27 | - run: pip install -r src/python-fastui/requirements/all.txt 28 | - run: pip install src/python-fastui 29 | 30 | - run: npm install 31 | 32 | - uses: pre-commit/action@v3.0.1 33 | with: 34 | extra_args: --all-files 35 | env: 36 | SKIP: no-commit-to-branch 37 | 38 | docs-build: 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: '3.11' 47 | 48 | # note: PPPR_TOKEN is not available on PRs sourced from forks, but the necessary 49 | # dependencies are also listed in docs.txt :) 50 | - name: install 51 | run: | 52 | pip install --upgrade pip 53 | pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python 54 | pip install -r requirements/docs.txt 55 | # note -- we can use these in the future when mkdocstrings-typescript and griffe-typedoc beocome publicly available 56 | # pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python griffe-typedoc mkdocstrings-typescript 57 | # npm install 58 | # npm install -g typedoc 59 | env: 60 | PPPR_TOKEN: ${{ secrets.PPPR_TOKEN }} 61 | 62 | - name: build site 63 | run: mkdocs build --strict 64 | 65 | test: 66 | name: test ${{ matrix.python-version }} on ${{ matrix.os }} 67 | strategy: 68 | fail-fast: false 69 | matrix: 70 | os: [ubuntu-latest, macos-13, macos-latest] 71 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 72 | exclude: 73 | # Python 3.8 and 3.9 are not available on macOS 14 74 | - os: macos-13 75 | python-version: '3.10' 76 | - os: macos-13 77 | python-version: '3.11' 78 | - os: macos-13 79 | python-version: '3.12' 80 | - os: macos-latest 81 | python-version: '3.8' 82 | - os: macos-latest 83 | python-version: '3.9' 84 | 85 | runs-on: ${{ matrix.os }} 86 | 87 | env: 88 | PYTHON: ${{ matrix.python-version }} 89 | OS: ${{ matrix.os }} 90 | 91 | steps: 92 | - uses: actions/checkout@v4 93 | 94 | - name: set up python 95 | uses: actions/setup-python@v5 96 | with: 97 | python-version: ${{ matrix.python-version }} 98 | 99 | - run: pip install -r src/python-fastui/requirements/test.txt 100 | - run: pip install -r src/python-fastui/requirements/pyproject.txt 101 | - run: pip install -e src/python-fastui 102 | 103 | - run: coverage run -m pytest src 104 | # display coverage and fail if it's below 80%, which shouldn't happen 105 | - run: coverage report --fail-under=80 106 | 107 | # test demo on 3.11 and 3.12, these tests are intentionally omitted from coverage 108 | - if: matrix.python-version == '3.11' || matrix.python-version == '3.12' 109 | run: pytest demo/tests.py 110 | 111 | - run: coverage xml 112 | 113 | - uses: codecov/codecov-action@v4 114 | with: 115 | file: ./coverage.xml 116 | env_vars: PYTHON,OS 117 | 118 | npm-build: 119 | runs-on: ubuntu-latest 120 | 121 | steps: 122 | - uses: actions/checkout@v4 123 | 124 | - uses: actions/setup-node@v4 125 | with: 126 | node-version: 18 127 | 128 | - run: npm install 129 | - run: npm run build 130 | - run: tree src 131 | 132 | check: # This job does nothing and is only used for the branch protection 133 | if: always() 134 | needs: [lint, test, npm-build] 135 | runs-on: ubuntu-latest 136 | 137 | steps: 138 | - name: Decide whether the needed jobs succeeded or failed 139 | uses: re-actors/alls-green@release/v1 140 | id: all-green 141 | with: 142 | jobs: ${{ toJSON(needs) }} 143 | 144 | release: 145 | needs: [check] 146 | if: "success() && startsWith(github.ref, 'refs/tags/')" 147 | runs-on: ubuntu-latest 148 | environment: release 149 | 150 | permissions: 151 | id-token: write 152 | 153 | steps: 154 | - uses: actions/checkout@v4 155 | 156 | - uses: actions/setup-python@v5 157 | with: 158 | python-version: '3.11' 159 | 160 | - run: pip install -U build 161 | 162 | - id: check-version 163 | uses: samuelcolvin/check-python-version@v4.1 164 | with: 165 | version_file_path: 'src/python-fastui/fastui/__init__.py' 166 | 167 | - run: python -m build --outdir dist src/python-fastui 168 | 169 | - run: ls -lh dist 170 | 171 | - uses: pypa/gh-action-pypi-publish@release/v1 172 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | /**/*.egg-info 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # python 28 | /env*/ 29 | __pycache__/ 30 | 31 | /.logfire/ 32 | /frontend-dist/ 33 | /scratch/ 34 | /packages-dist/ 35 | /.coverage 36 | /users.db 37 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: no-commit-to-branch 6 | - id: check-yaml 7 | args: ['--unsafe'] 8 | - id: check-toml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | 12 | - repo: local 13 | hooks: 14 | - id: python-format 15 | name: python-format 16 | types_or: [python] 17 | entry: make format 18 | language: system 19 | pass_filenames: false 20 | - id: python-typecheck 21 | name: python-typecheck 22 | types_or: [python] 23 | entry: make typecheck 24 | language: system 25 | pass_filenames: false 26 | - id: js-prettier 27 | name: js-prettier 28 | types_or: [javascript, jsx, ts, tsx, css, json, markdown] 29 | entry: npm run prettier 30 | language: system 31 | exclude: '^docs/.*' 32 | - id: js-lint 33 | name: js-lint 34 | types_or: [ts, tsx] 35 | entry: npm run lint-fix 36 | language: system 37 | pass_filenames: false 38 | - id: js-typecheck 39 | name: js-typecheck 40 | types_or: [ts, tsx] 41 | entry: npm run typecheck 42 | language: system 43 | pass_filenames: false 44 | - id: python-generate-ts 45 | name: python-generate-ts 46 | types_or: [python] 47 | entry: make typescript-models 48 | language: system 49 | pass_filenames: false 50 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | htmlcov/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 to present Pydantic Services inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL:=all 2 | path = src/python-fastui 3 | 4 | .PHONY: install 5 | install: 6 | pip install -U pip pre-commit pip-tools 7 | pip install -r $(path)/requirements/all.txt 8 | pip install -e $(path) 9 | pre-commit install 10 | 11 | 12 | .PHONY: install-docs 13 | install-docs: 14 | pip install -r requirements/docs.txt 15 | 16 | # note -- mkdocstrings-typescript and griffe-typedoc are not yet publicly available 17 | # but the following can be added above the pip install -r requirements/docs.txt line in the future 18 | # pip install mkdocstrings-python mkdocstrings-typescript griffe-typedoc 19 | 20 | .PHONY: update-lockfiles 21 | update-lockfiles: 22 | @echo "Updating requirements files using pip-compile" 23 | pip-compile -q --strip-extras -o $(path)/requirements/lint.txt $(path)/requirements/lint.in 24 | pip-compile -q --strip-extras -o $(path)/requirements/pyproject.txt -c $(path)/requirements/lint.txt $(path)/pyproject.toml --extra=fastapi 25 | pip-compile -q --strip-extras -o $(path)/requirements/test.txt -c $(path)/requirements/lint.txt -c $(path)/requirements/pyproject.txt $(path)/requirements/test.in 26 | pip install --dry-run -r $(path)/requirements/all.txt 27 | 28 | .PHONY: format 29 | format: 30 | ruff check --fix-only $(path) demo 31 | ruff format $(path) demo 32 | 33 | .PHONY: lint 34 | lint: 35 | ruff check $(path) demo 36 | ruff format --check $(path) demo 37 | 38 | .PHONY: typecheck 39 | typecheck: 40 | pyright 41 | 42 | .PHONY: test 43 | test: 44 | coverage run -m pytest 45 | 46 | .PHONY: testcov 47 | testcov: test 48 | coverage html 49 | 50 | .PHONY: typescript-models 51 | typescript-models: 52 | fastui generate fastui:FastUI src/npm-fastui/src/models.d.ts 53 | 54 | .PHONY: dev 55 | dev: 56 | uvicorn demo:app --reload --reload-dir . 57 | 58 | .PHONY: docs 59 | docs: 60 | mkdocs build 61 | 62 | .PHONY: serve 63 | serve: 64 | mkdocs serve 65 | 66 | .PHONY: all 67 | all: testcov lint 68 | -------------------------------------------------------------------------------- /build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | python3 -V 7 | 8 | python3 -m pip install --extra-index-url https://pydantic:${PPPR_TOKEN}@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python 9 | python3 -m pip install -r ./requirements/docs.txt 10 | # note -- we can use these in the future when mkdocstrings-typescript and griffe-typedoc beocome publicly available 11 | # python3 -m pip install --extra-index-url https://pydantic:$PPPR_TOKEN@pppr.pydantic.dev/simple/ mkdocs-material mkdocstrings-python griffe-typedoc mkdocstrings-typescript 12 | # npm install 13 | # npm install -g typedoc 14 | 15 | python3 -m mkdocs build 16 | -------------------------------------------------------------------------------- /bump_npm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import json 5 | import re 6 | from pathlib import Path 7 | 8 | 9 | def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> tuple[Path, str]: 10 | content = package_json.read_text() 11 | content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1) 12 | assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' 13 | if deps: 14 | content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content) 15 | assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' 16 | 17 | return package_json, content 18 | 19 | 20 | def main(): 21 | this_dir = Path(__file__).parent 22 | fastui_package_json = this_dir / 'src/npm-fastui/package.json' 23 | with fastui_package_json.open() as f: 24 | old_version = json.load(f)['version'] 25 | 26 | rest, patch_version = old_version.rsplit('.', 1) 27 | new_version = f'{rest}.{int(patch_version) + 1}' 28 | bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json' 29 | prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json' 30 | to_update: list[tuple[Path, str]] = [ 31 | replace_package_json(fastui_package_json, new_version), 32 | replace_package_json(bootstrap_package_json, new_version, deps=True), 33 | replace_package_json(prebuilt_package_json, new_version), 34 | ] 35 | 36 | python_init = this_dir / 'src/python-fastui/fastui/__init__.py' 37 | python_content = python_init.read_text() 38 | python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content) 39 | assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}' 40 | to_update.append((python_init, python_content)) 41 | 42 | # logic is finished, no update all files 43 | print(f'Updating files:') 44 | for package_json, content in to_update: 45 | print(f' {package_json.relative_to(this_dir)}') 46 | package_json.write_text(content) 47 | 48 | print(f""" 49 | Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files. 50 | 51 | To publish the new version, run: 52 | 53 | > npm --workspaces publish 54 | """) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # FastUI Demo 2 | 3 | This a simple demo app for FastUI, it's deployed at [fastui-demo.onrender.com](https://fastui-demo.onrender.com). 4 | 5 | ## Running 6 | 7 | To run the demo app, execute the following commands from the FastUI repo root 8 | 9 | ```bash 10 | # create a virtual env 11 | python3.11 -m venv env311 12 | # activate the env 13 | . env311/bin/activate 14 | # install deps 15 | make install 16 | # run the demo server 17 | make dev 18 | ``` 19 | 20 | Then navigate to [http://localhost:8000](http://localhost:8000) 21 | 22 | If you want to run the dev version of the React frontend, run 23 | 24 | ```bash 25 | npm install 26 | npm run dev 27 | ``` 28 | 29 | This will run at [http://localhost:3000](http://localhost:3000), and connect to the backend running at `localhost:3000`. 30 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import sys 4 | from contextlib import asynccontextmanager 5 | 6 | from fastapi import FastAPI 7 | from fastapi.responses import HTMLResponse, PlainTextResponse 8 | from fastui import prebuilt_html 9 | from fastui.auth import fastapi_auth_exception_handling 10 | from fastui.dev import dev_fastapi_app 11 | from httpx import AsyncClient 12 | 13 | from .auth import router as auth_router 14 | from .components_list import router as components_router 15 | from .forms import router as forms_router 16 | from .main import router as main_router 17 | from .sse import router as sse_router 18 | from .tables import router as table_router 19 | 20 | 21 | @asynccontextmanager 22 | async def lifespan(app_: FastAPI): 23 | async with AsyncClient() as client: 24 | app_.state.httpx_client = client 25 | yield 26 | 27 | 28 | frontend_reload = '--reload' in sys.argv 29 | if frontend_reload: 30 | # dev_fastapi_app reloads in the browser when the Python source changes 31 | app = dev_fastapi_app(lifespan=lifespan) 32 | else: 33 | app = FastAPI(lifespan=lifespan) 34 | 35 | fastapi_auth_exception_handling(app) 36 | app.include_router(components_router, prefix='/api/components') 37 | app.include_router(sse_router, prefix='/api/components') 38 | app.include_router(table_router, prefix='/api/table') 39 | app.include_router(forms_router, prefix='/api/forms') 40 | app.include_router(auth_router, prefix='/api/auth') 41 | app.include_router(main_router, prefix='/api') 42 | 43 | 44 | @app.get('/robots.txt', response_class=PlainTextResponse) 45 | async def robots_txt() -> str: 46 | return 'User-agent: *\nAllow: /' 47 | 48 | 49 | @app.get('/favicon.ico', status_code=404, response_class=PlainTextResponse) 50 | async def favicon_ico() -> str: 51 | return 'page not found' 52 | 53 | 54 | @app.get('/{path:path}') 55 | async def html_landing() -> HTMLResponse: 56 | return HTMLResponse(prebuilt_html(title='FastUI Demo')) 57 | -------------------------------------------------------------------------------- /demo/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import asyncio 4 | import json 5 | import os 6 | from dataclasses import asdict 7 | from typing import Annotated, Literal, TypeAlias 8 | 9 | from fastapi import APIRouter, Depends, Request 10 | from fastui import AnyComponent, FastUI 11 | from fastui import components as c 12 | from fastui.auth import AuthRedirect, GitHubAuthProvider 13 | from fastui.events import AuthEvent, GoToEvent, PageEvent 14 | from fastui.forms import fastui_form 15 | from httpx import AsyncClient 16 | from pydantic import BaseModel, EmailStr, Field, SecretStr 17 | 18 | from .auth_user import User 19 | from .shared import demo_page 20 | 21 | router = APIRouter() 22 | 23 | GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '0d0315f9c2e055d032e2') 24 | # this will give an error when making requests to GitHub, but at least the app will run 25 | GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret')) 26 | # use 'http://localhost:3000/auth/login/github/redirect' in development 27 | GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT') 28 | 29 | 30 | async def get_github_auth(request: Request) -> GitHubAuthProvider: 31 | client: AsyncClient = request.app.state.httpx_client 32 | return GitHubAuthProvider( 33 | httpx_client=client, 34 | github_client_id=GITHUB_CLIENT_ID, 35 | github_client_secret=GITHUB_CLIENT_SECRET, 36 | redirect_uri=GITHUB_REDIRECT, 37 | scopes=['user:email'], 38 | ) 39 | 40 | 41 | LoginKind: TypeAlias = Literal['password', 'github'] 42 | 43 | 44 | @router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) 45 | def auth_login( 46 | kind: LoginKind, 47 | user: Annotated[User | None, Depends(User.from_request_opt)], 48 | ) -> list[AnyComponent]: 49 | if user is not None: 50 | # already logged in 51 | raise AuthRedirect('/auth/profile') 52 | 53 | return demo_page( 54 | c.LinkList( 55 | links=[ 56 | c.Link( 57 | components=[c.Text(text='Password Login')], 58 | on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}), 59 | active='/auth/login/password', 60 | ), 61 | c.Link( 62 | components=[c.Text(text='GitHub Login')], 63 | on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}), 64 | active='/auth/login/github', 65 | ), 66 | ], 67 | mode='tabs', 68 | class_name='+ mb-4', 69 | ), 70 | c.ServerLoad( 71 | path='/auth/login/content/{kind}', 72 | load_trigger=PageEvent(name='tab'), 73 | components=auth_login_content(kind), 74 | ), 75 | title='Authentication', 76 | ) 77 | 78 | 79 | @router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True) 80 | def auth_login_content(kind: LoginKind) -> list[AnyComponent]: 81 | match kind: 82 | case 'password': 83 | return [ 84 | c.Heading(text='Password Login', level=3), 85 | c.Paragraph( 86 | text=( 87 | 'This is a very simple demo of password authentication, ' 88 | 'here you can "login" with any email address and password.' 89 | ) 90 | ), 91 | c.Paragraph(text='(Passwords are not saved and is email stored in the browser via a JWT only)'), 92 | c.ModelForm(model=LoginForm, submit_url='/api/auth/login', display_mode='page'), 93 | ] 94 | case 'github': 95 | return [ 96 | c.Heading(text='GitHub Login', level=3), 97 | c.Paragraph(text='Demo of GitHub authentication.'), 98 | c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'), 99 | c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')), 100 | ] 101 | case _: 102 | raise ValueError(f'Invalid kind {kind!r}') 103 | 104 | 105 | class LoginForm(BaseModel): 106 | email: EmailStr = Field( 107 | title='Email Address', description='Enter whatever value you like', json_schema_extra={'autocomplete': 'email'} 108 | ) 109 | password: SecretStr = Field( 110 | title='Password', 111 | description='Enter whatever value you like, password is not checked', 112 | json_schema_extra={'autocomplete': 'current-password'}, 113 | ) 114 | 115 | 116 | @router.post('/login', response_model=FastUI, response_model_exclude_none=True) 117 | async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]: 118 | user = User(email=form.email, extra={}) 119 | token = user.encode_token() 120 | return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] 121 | 122 | 123 | @router.get('/profile', response_model=FastUI, response_model_exclude_none=True) 124 | async def profile(user: Annotated[User, Depends(User.from_request)]) -> list[AnyComponent]: 125 | return demo_page( 126 | c.Paragraph(text=f'You are logged in as "{user.email}".'), 127 | c.Button(text='Logout', on_click=PageEvent(name='submit-form')), 128 | c.Heading(text='User Data:', level=3), 129 | c.Code(language='json', text=json.dumps(asdict(user), indent=2)), 130 | c.Form( 131 | submit_url='/api/auth/logout', 132 | form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')], 133 | footer=[], 134 | submit_trigger=PageEvent(name='submit-form'), 135 | ), 136 | title='Authentication', 137 | ) 138 | 139 | 140 | @router.post('/logout', response_model=FastUI, response_model_exclude_none=True) 141 | async def logout_form_post() -> list[AnyComponent]: 142 | return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))] 143 | 144 | 145 | @router.get('/login/github/gen', response_model=FastUI, response_model_exclude_none=True) 146 | async def auth_github_gen(github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]) -> list[AnyComponent]: 147 | auth_url = await github_auth.authorization_url() 148 | return [c.FireEvent(event=GoToEvent(url=auth_url))] 149 | 150 | 151 | @router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True) 152 | async def github_redirect( 153 | code: str, 154 | state: str | None, 155 | github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], 156 | ) -> list[AnyComponent]: 157 | exchange = await github_auth.exchange_code(code, state) 158 | user_info, emails = await asyncio.gather( 159 | github_auth.get_github_user(exchange), github_auth.get_github_user_emails(exchange) 160 | ) 161 | user = User( 162 | email=next((e.email for e in emails if e.primary and e.verified), None), 163 | extra={ 164 | 'github_user_info': user_info.model_dump(), 165 | 'github_emails': [e.model_dump() for e in emails], 166 | }, 167 | ) 168 | token = user.encode_token() 169 | return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] 170 | -------------------------------------------------------------------------------- /demo/auth_user.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import asdict, dataclass 3 | from datetime import datetime, timedelta 4 | from typing import Annotated, Any 5 | 6 | import jwt 7 | from fastapi import Header, HTTPException 8 | from fastui.auth import AuthRedirect 9 | from typing_extensions import Self 10 | 11 | JWT_SECRET = 'secret' 12 | 13 | 14 | @dataclass 15 | class User: 16 | email: str | None 17 | extra: dict[str, Any] 18 | 19 | def encode_token(self) -> str: 20 | payload = asdict(self) 21 | payload['exp'] = datetime.now() + timedelta(hours=1) 22 | return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) 23 | 24 | @classmethod 25 | def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self: 26 | user = cls.from_request_opt(authorization) 27 | if user is None: 28 | raise AuthRedirect('/auth/login/password') 29 | else: 30 | return user 31 | 32 | @classmethod 33 | def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self | None: 34 | try: 35 | token = authorization.split(' ', 1)[1] 36 | except IndexError: 37 | return None 38 | 39 | try: 40 | payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) 41 | except jwt.ExpiredSignatureError: 42 | return None 43 | except jwt.DecodeError: 44 | raise HTTPException(status_code=401, detail='Invalid token') 45 | else: 46 | # existing token might not have 'exp' field 47 | payload.pop('exp', None) 48 | return cls(**payload) 49 | 50 | 51 | class CustomJsonEncoder(json.JSONEncoder): 52 | def default(self, obj: Any) -> Any: 53 | if isinstance(obj, datetime): 54 | return obj.isoformat() 55 | else: 56 | return super().default(obj) 57 | -------------------------------------------------------------------------------- /demo/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import enum 4 | from collections import defaultdict 5 | from datetime import date 6 | from typing import Annotated, Literal, TypeAlias 7 | 8 | from fastapi import APIRouter, Request, UploadFile 9 | from fastui import AnyComponent, FastUI 10 | from fastui import components as c 11 | from fastui.events import GoToEvent, PageEvent 12 | from fastui.forms import FormFile, SelectSearchResponse, Textarea, fastui_form 13 | from httpx import AsyncClient 14 | from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator 15 | from pydantic_core import PydanticCustomError 16 | 17 | from .shared import demo_page 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.get('/search', response_model=SelectSearchResponse) 23 | async def search_view(request: Request, q: str) -> SelectSearchResponse: 24 | path_ends = f'name/{q}' if q else 'all' 25 | client: AsyncClient = request.app.state.httpx_client 26 | r = await client.get(f'https://restcountries.com/v3.1/{path_ends}') 27 | if r.status_code == 404: 28 | options = [] 29 | else: 30 | r.raise_for_status() 31 | data = r.json() 32 | if path_ends == 'all': 33 | # if we got all, filter to the 20 most populous countries 34 | data.sort(key=lambda x: x['population'], reverse=True) 35 | data = data[0:20] 36 | data.sort(key=lambda x: x['name']['common']) 37 | 38 | regions = defaultdict(list) 39 | for co in data: 40 | regions[co['region']].append({'value': co['cca3'], 'label': co['name']['common']}) 41 | options = [{'label': k, 'options': v} for k, v in regions.items()] 42 | return SelectSearchResponse(options=options) 43 | 44 | 45 | FormKind: TypeAlias = Literal['login', 'select', 'big'] 46 | 47 | 48 | @router.get('/{kind}', response_model=FastUI, response_model_exclude_none=True) 49 | def forms_view(kind: FormKind) -> list[AnyComponent]: 50 | return demo_page( 51 | c.LinkList( 52 | links=[ 53 | c.Link( 54 | components=[c.Text(text='Login Form')], 55 | on_click=PageEvent(name='change-form', push_path='/forms/login', context={'kind': 'login'}), 56 | active='/forms/login', 57 | ), 58 | c.Link( 59 | components=[c.Text(text='Select Form')], 60 | on_click=PageEvent(name='change-form', push_path='/forms/select', context={'kind': 'select'}), 61 | active='/forms/select', 62 | ), 63 | c.Link( 64 | components=[c.Text(text='Big Form')], 65 | on_click=PageEvent(name='change-form', push_path='/forms/big', context={'kind': 'big'}), 66 | active='/forms/big', 67 | ), 68 | ], 69 | mode='tabs', 70 | class_name='+ mb-4', 71 | ), 72 | c.ServerLoad( 73 | path='/forms/content/{kind}', 74 | load_trigger=PageEvent(name='change-form'), 75 | components=form_content(kind), 76 | ), 77 | title='Forms', 78 | ) 79 | 80 | 81 | @router.get('/content/{kind}', response_model=FastUI, response_model_exclude_none=True) 82 | def form_content(kind: FormKind): 83 | match kind: 84 | case 'login': 85 | return [ 86 | c.Heading(text='Login Form', level=2), 87 | c.Paragraph(text='Simple login form with email and password.'), 88 | c.ModelForm(model=LoginForm, display_mode='page', submit_url='/api/forms/login'), 89 | ] 90 | case 'select': 91 | return [ 92 | c.Heading(text='Select Form', level=2), 93 | c.Paragraph(text='Form showing different ways of doing select.'), 94 | c.ModelForm(model=SelectForm, display_mode='page', submit_url='/api/forms/select'), 95 | ] 96 | case 'big': 97 | return [ 98 | c.Heading(text='Large Form', level=2), 99 | c.Paragraph(text='Form with a lot of fields.'), 100 | c.ModelForm(model=BigModel, display_mode='page', submit_url='/api/forms/big'), 101 | ] 102 | case _: 103 | raise ValueError(f'Invalid kind {kind!r}') 104 | 105 | 106 | class LoginForm(BaseModel): 107 | email: EmailStr = Field(title='Email Address', description="Try 'x@y' to trigger server side validation") 108 | password: SecretStr 109 | 110 | 111 | @router.post('/login', response_model=FastUI, response_model_exclude_none=True) 112 | async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]): 113 | print(form) 114 | return [c.FireEvent(event=GoToEvent(url='/'))] 115 | 116 | 117 | class ToolEnum(str, enum.Enum): 118 | hammer = 'hammer' 119 | screwdriver = 'screwdriver' 120 | saw = 'saw' 121 | claw_hammer = 'claw_hammer' 122 | 123 | 124 | class SelectForm(BaseModel): 125 | select_single: ToolEnum = Field(title='Select Single') 126 | select_multiple: list[ToolEnum] = Field(title='Select Multiple') 127 | search_select_single: str = Field(json_schema_extra={'search_url': '/api/forms/search'}) 128 | search_select_multiple: list[str] = Field(json_schema_extra={'search_url': '/api/forms/search'}) 129 | 130 | @field_validator('select_multiple', 'search_select_multiple', mode='before') 131 | @classmethod 132 | def correct_select_multiple(cls, v: list[str]) -> list[str]: 133 | if isinstance(v, list): 134 | return v 135 | else: 136 | return [v] 137 | 138 | 139 | @router.post('/select', response_model=FastUI, response_model_exclude_none=True) 140 | async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]): 141 | # print(form) 142 | return [c.FireEvent(event=GoToEvent(url='/'))] 143 | 144 | 145 | class SizeModel(BaseModel): 146 | width: int = Field(description='This is a field of a nested model') 147 | height: int = Field(description='This is a field of a nested model') 148 | 149 | 150 | class BigModel(BaseModel): 151 | name: str | None = Field( 152 | None, description='This field is not required, it must start with a capital letter if provided' 153 | ) 154 | info: Annotated[str | None, Textarea(rows=5)] = Field(None, description='Optional free text information about you.') 155 | repo: str = Field(json_schema_extra={'placeholder': '{org}/{repo}'}, title='GitHub repository') 156 | profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] = Field( 157 | description='Upload a profile picture, must not be more than 16kb' 158 | ) 159 | profile_pics: Annotated[list[UploadFile], FormFile(accept='image/*')] | None = Field( 160 | None, description='Upload multiple images' 161 | ) 162 | dob: date = Field(title='Date of Birth', description='Your date of birth, this is required hence bold') 163 | human: bool | None = Field( 164 | None, title='Is human', description='Are you human?', json_schema_extra={'mode': 'switch'} 165 | ) 166 | size: SizeModel 167 | 168 | position: tuple[ 169 | Annotated[int, Field(description='X Coordinate')], 170 | Annotated[int, Field(description='Y Coordinate')], 171 | ] 172 | 173 | @field_validator('name') 174 | def name_validator(cls, v: str | None) -> str: 175 | if v and v[0].islower(): 176 | raise PydanticCustomError('lower', 'Name must start with a capital letter') 177 | return v 178 | 179 | 180 | @router.post('/big', response_model=FastUI, response_model_exclude_none=True) 181 | async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]): 182 | print(form) 183 | return [c.FireEvent(event=GoToEvent(url='/'))] 184 | -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from fastapi import APIRouter 4 | from fastui import AnyComponent, FastUI 5 | from fastui import components as c 6 | 7 | from .shared import demo_page 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get('/', response_model=FastUI, response_model_exclude_none=True) 13 | def api_index() -> list[AnyComponent]: 14 | # language=markdown 15 | markdown = """\ 16 | This site provides a demo of [FastUI](https://github.com/pydantic/FastUI), the code for the demo 17 | is [here](https://github.com/pydantic/FastUI/tree/main/demo). 18 | 19 | You can find the documentation for FastUI [here](https://docs.pydantic.dev/fastui/). 20 | 21 | The following components are demonstrated: 22 | 23 | * `Markdown` — that's me :-) 24 | * `Text`— example [here](/components#text) 25 | * `Paragraph` — example [here](/components#paragraph) 26 | * `PageTitle` — you'll see the title in the browser tab change when you navigate through the site 27 | * `Heading` — example [here](/components#heading) 28 | * `Code` — example [here](/components#code) 29 | * `Button` — example [here](/components#button-and-modal) 30 | * `Link` — example [here](/components#link-list) 31 | * `LinkList` — example [here](/components#link-list) 32 | * `Navbar` — see the top of this page 33 | * `Footer` — see the bottom of this page 34 | * `Modal` — static example [here](/components#button-and-modal), dynamic content example [here](/components#dynamic-modal) 35 | * `ServerLoad` — see [dynamic modal example](/components#dynamic-modal) and [SSE example](/components#server-load-sse) 36 | * `Image` - example [here](/components#image) 37 | * `Iframe` - example [here](/components#iframe) 38 | * `Video` - example [here](/components#video) 39 | * `Toast` - example [here](/components#toast) 40 | * `Table` — See [cities table](/table/cities) and [users table](/table/users) 41 | * `Pagination` — See the bottom of the [cities table](/table/cities) 42 | * `ModelForm` — See [forms](/forms/login) 43 | 44 | Authentication is supported via: 45 | * token based authentication — see [here](/auth/login/password) for an example of password authentication 46 | * GitHub OAuth — see [here](/auth/login/github) for an example of GitHub OAuth login 47 | """ 48 | return demo_page(c.Markdown(text=markdown)) 49 | 50 | 51 | @router.get('/{path:path}', status_code=404) 52 | async def api_404(): 53 | # so we don't fall through to the index page 54 | return {'message': 'Not Found'} 55 | -------------------------------------------------------------------------------- /demo/shared.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from fastui import AnyComponent 4 | from fastui import components as c 5 | from fastui.events import GoToEvent 6 | 7 | 8 | def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyComponent]: 9 | return [ 10 | c.PageTitle(text=f'FastUI Demo — {title}' if title else 'FastUI Demo'), 11 | c.Navbar( 12 | title='FastUI Demo', 13 | title_event=GoToEvent(url='/'), 14 | start_links=[ 15 | c.Link( 16 | components=[c.Text(text='Components')], 17 | on_click=GoToEvent(url='/components'), 18 | active='startswith:/components', 19 | ), 20 | c.Link( 21 | components=[c.Text(text='Tables')], 22 | on_click=GoToEvent(url='/table/cities'), 23 | active='startswith:/table', 24 | ), 25 | c.Link( 26 | components=[c.Text(text='Auth')], 27 | on_click=GoToEvent(url='/auth/login/password'), 28 | active='startswith:/auth', 29 | ), 30 | c.Link( 31 | components=[c.Text(text='Forms')], 32 | on_click=GoToEvent(url='/forms/login'), 33 | active='startswith:/forms', 34 | ), 35 | ], 36 | ), 37 | c.Page( 38 | components=[ 39 | *((c.Heading(text=title),) if title else ()), 40 | *components, 41 | ], 42 | ), 43 | c.Footer( 44 | extra_text='FastUI Demo', 45 | links=[ 46 | c.Link( 47 | components=[c.Text(text='Github')], on_click=GoToEvent(url='https://github.com/pydantic/FastUI') 48 | ), 49 | c.Link(components=[c.Text(text='PyPI')], on_click=GoToEvent(url='https://pypi.org/project/fastui/')), 50 | c.Link(components=[c.Text(text='NPM')], on_click=GoToEvent(url='https://www.npmjs.com/org/pydantic/')), 51 | ], 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /demo/tables.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from functools import cache 3 | from pathlib import Path 4 | 5 | import pydantic 6 | from fastapi import APIRouter 7 | from fastui import AnyComponent, FastUI 8 | from fastui import components as c 9 | from fastui.components.display import DisplayLookup, DisplayMode 10 | from fastui.events import BackEvent, GoToEvent 11 | from pydantic import BaseModel, Field, TypeAdapter 12 | 13 | from .shared import demo_page 14 | 15 | router = APIRouter() 16 | 17 | 18 | class City(BaseModel): 19 | id: int = Field(title='ID') 20 | city: str = Field(title='Name') 21 | city_ascii: str = Field(title='City Ascii') 22 | lat: float = Field(title='Latitude') 23 | lng: float = Field(title='Longitude') 24 | country: str = Field(title='Country') 25 | iso2: str = Field(title='ISO2') 26 | iso3: str = Field(title='ISO3') 27 | admin_name: str | None = Field(title='Admin Name') 28 | capital: str | None = Field(title='Capital') 29 | population: float = Field(title='Population') 30 | 31 | 32 | @cache 33 | def cities_list() -> list[City]: 34 | cities_adapter = TypeAdapter(list[City]) 35 | cities_file = Path(__file__).parent / 'cities.json' 36 | cities = cities_adapter.validate_json(cities_file.read_bytes()) 37 | cities.sort(key=lambda city: city.population, reverse=True) 38 | return cities 39 | 40 | 41 | @cache 42 | def cities_lookup() -> dict[id, City]: 43 | return {city.id: city for city in cities_list()} 44 | 45 | 46 | class FilterForm(pydantic.BaseModel): 47 | country: str = Field(json_schema_extra={'search_url': '/api/forms/search', 'placeholder': 'Filter by Country...'}) 48 | 49 | 50 | @router.get('/cities', response_model=FastUI, response_model_exclude_none=True) 51 | def cities_view(page: int = 1, country: str | None = None) -> list[AnyComponent]: 52 | cities = cities_list() 53 | page_size = 50 54 | filter_form_initial = {} 55 | if country: 56 | cities = [city for city in cities if city.iso3 == country] 57 | country_name = cities[0].country if cities else country 58 | filter_form_initial['country'] = {'value': country, 'label': country_name} 59 | return demo_page( 60 | *tabs(), 61 | c.ModelForm( 62 | model=FilterForm, 63 | submit_url='.', 64 | initial=filter_form_initial, 65 | method='GOTO', 66 | submit_on_change=True, 67 | display_mode='inline', 68 | ), 69 | c.Table( 70 | data=cities[(page - 1) * page_size : page * page_size], 71 | data_model=City, 72 | columns=[ 73 | DisplayLookup(field='city', on_click=GoToEvent(url='./{id}'), table_width_percent=33), 74 | DisplayLookup(field='country', table_width_percent=33), 75 | DisplayLookup(field='population', table_width_percent=33), 76 | ], 77 | ), 78 | c.Pagination(page=page, page_size=page_size, total=len(cities)), 79 | title='Cities', 80 | ) 81 | 82 | 83 | @router.get('/cities/{city_id}', response_model=FastUI, response_model_exclude_none=True) 84 | def city_view(city_id: int) -> list[AnyComponent]: 85 | city = cities_lookup()[city_id] 86 | return demo_page( 87 | *tabs(), 88 | c.Link(components=[c.Text(text='Back')], on_click=BackEvent()), 89 | c.Details(data=city), 90 | title=city.city, 91 | ) 92 | 93 | 94 | class User(BaseModel): 95 | id: int = Field(title='ID') 96 | name: str = Field(title='Name') 97 | dob: date = Field(title='Date of Birth') 98 | enabled: bool | None = None 99 | status_markdown: str | None = Field(default=None, title='Status') 100 | 101 | 102 | users: list[User] = [ 103 | User(id=1, name='John', dob=date(1990, 1, 1), enabled=True, status_markdown='**Active**'), 104 | User(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False, status_markdown='*Inactive*'), 105 | User(id=3, name='Jack', dob=date(1992, 1, 1)), 106 | ] 107 | 108 | 109 | @router.get('/users', response_model=FastUI, response_model_exclude_none=True) 110 | def users_view() -> list[AnyComponent]: 111 | return demo_page( 112 | *tabs(), 113 | c.Table( 114 | data=users, 115 | columns=[ 116 | DisplayLookup(field='name', on_click=GoToEvent(url='/table/users/{id}/')), 117 | DisplayLookup(field='dob', mode=DisplayMode.date), 118 | DisplayLookup(field='enabled'), 119 | DisplayLookup(field='status_markdown', mode=DisplayMode.markdown), 120 | ], 121 | ), 122 | title='Users', 123 | ) 124 | 125 | 126 | def tabs() -> list[AnyComponent]: 127 | return [ 128 | c.LinkList( 129 | links=[ 130 | c.Link( 131 | components=[c.Text(text='Cities')], 132 | on_click=GoToEvent(url='/table/cities'), 133 | active='startswith:/table/cities', 134 | ), 135 | c.Link( 136 | components=[c.Text(text='Users')], 137 | on_click=GoToEvent(url='/table/users'), 138 | active='startswith:/table/users', 139 | ), 140 | ], 141 | mode='tabs', 142 | class_name='+ mb-4', 143 | ), 144 | ] 145 | 146 | 147 | @router.get('/users/{id}/', response_model=FastUI, response_model_exclude_none=True) 148 | def user_profile(id: int) -> list[AnyComponent]: 149 | user: User | None = users[id - 1] if id <= len(users) else None 150 | return demo_page( 151 | *tabs(), 152 | c.Link(components=[c.Text(text='Back')], on_click=BackEvent()), 153 | c.Details( 154 | data=user, 155 | fields=[ 156 | DisplayLookup(field='name'), 157 | DisplayLookup(field='dob', mode=DisplayMode.date), 158 | DisplayLookup(field='enabled'), 159 | ], 160 | ), 161 | title=user.name, 162 | ) 163 | -------------------------------------------------------------------------------- /demo/tests.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | from dirty_equals import IsList, IsStr 5 | from fastapi.testclient import TestClient 6 | 7 | from . import app 8 | 9 | 10 | @pytest.fixture 11 | def client(): 12 | with TestClient(app) as test_client: 13 | yield test_client 14 | 15 | 16 | def test_index(client: TestClient): 17 | r = client.get('/') 18 | assert r.status_code == 200, r.text 19 | assert r.text.startswith('\n') 20 | assert r.headers.get('content-type') == 'text/html; charset=utf-8' 21 | 22 | 23 | def test_api_root(client: TestClient): 24 | r = client.get('/api/') 25 | assert r.status_code == 200 26 | data = r.json() 27 | assert data == [ 28 | { 29 | 'text': 'FastUI Demo', 30 | 'type': 'PageTitle', 31 | }, 32 | { 33 | 'title': 'FastUI Demo', 34 | 'titleEvent': {'url': '/', 'type': 'go-to'}, 35 | 'startLinks': IsList(length=4), 36 | 'endLinks': [], 37 | 'type': 'Navbar', 38 | }, 39 | { 40 | 'components': [ 41 | { 42 | 'text': IsStr(regex='This site provides a demo of.*', regex_flags=re.DOTALL), 43 | 'type': 'Markdown', 44 | }, 45 | ], 46 | 'type': 'Page', 47 | }, 48 | { 49 | 'extraText': 'FastUI Demo', 50 | 'links': IsList(length=3), 51 | 'type': 'Footer', 52 | }, 53 | ] 54 | 55 | 56 | def get_menu_links(): 57 | """ 58 | This is pretty cursory, we just go through the menu and load each page. 59 | """ 60 | with TestClient(app) as client: 61 | r = client.get('/api/') 62 | assert r.status_code == 200 63 | data = r.json() 64 | for link in data[1]['startLinks']: 65 | url = link['onClick']['url'] 66 | yield pytest.param(f'/api{url}', id=url) 67 | 68 | 69 | @pytest.mark.parametrize('url', get_menu_links()) 70 | def test_menu_links(client: TestClient, url: str): 71 | r = client.get(url) 72 | assert r.status_code == 200 73 | data = r.json() 74 | assert isinstance(data, list) 75 | 76 | 77 | # def test_forms_validate_correct_select_multiple(client: TestClient): 78 | # countries = client.get('api/forms/search', params={'q': None}) 79 | # countries_options = countries.json()['options'] 80 | # r = client.post( 81 | # 'api/forms/select', 82 | # data={ 83 | # 'select_single': ToolEnum._member_names_[0], 84 | # 'select_multiple': ToolEnum._member_names_[0], 85 | # 'search_select_single': countries_options[0]['options'][0]['value'], 86 | # 'search_select_multiple': countries_options[0]['options'][0]['value'], 87 | # }, 88 | # ) 89 | # assert r.status_code == 200 90 | 91 | 92 | # TODO tests for forms, including submission 93 | -------------------------------------------------------------------------------- /docs/api/python_components.md: -------------------------------------------------------------------------------- 1 | # Python Components 2 | 3 | ::: fastui.components 4 | handler: python 5 | options: 6 | inherited_members: true 7 | docstring_options: 8 | ignore_init_summary: false 9 | members: 10 | - Text 11 | - Paragraph 12 | - PageTitle 13 | - Div 14 | - Page 15 | - Heading 16 | - Markdown 17 | - Code 18 | - Json 19 | - Button 20 | - Link 21 | - LinkList 22 | - Navbar 23 | - Modal 24 | - ServerLoad 25 | - Image 26 | - Iframe 27 | - FireEvent 28 | - Error 29 | - Spinner 30 | - Toast 31 | - Custom 32 | - Table 33 | - Pagination 34 | - Display 35 | - Details 36 | - Form 37 | - FormField 38 | - ModelForm 39 | - Footer 40 | - AnyComponent 41 | - FormFieldBoolean 42 | - FormFieldFile 43 | - FormFieldInput 44 | - FormFieldSelect 45 | - FormFieldSelectSearch 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/api/typescript_components.md: -------------------------------------------------------------------------------- 1 | # TypeScript Components 2 | 3 | !!! warning "🚧 Work in Progress" 4 | This page is a work in progress. 5 | 6 | 8 | 10 | -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/FastUI/3df5edd9b8c1d83efcad29614e69cf8d5266b9f3/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/assets/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /docs/extra/tweaks.css: -------------------------------------------------------------------------------- 1 | /* Revert hue value to that of pre mkdocs-material v9.4.0 */ 2 | [data-md-color-scheme='slate'] { 3 | --md-hue: 230; 4 | --md-default-bg-color: hsla(230, 15%, 21%, 1); 5 | } 6 | -------------------------------------------------------------------------------- /docs/guide.md: -------------------------------------------------------------------------------- 1 | !!! warning "🚧 Work in Progress" 2 | This page is a work in progress. 3 | -------------------------------------------------------------------------------- /docs/plugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from typing import Match 5 | 6 | from mkdocs.config import Config 7 | from mkdocs.structure.files import Files 8 | from mkdocs.structure.pages import Page 9 | 10 | try: 11 | import pytest 12 | except ImportError: 13 | pytest = None 14 | 15 | 16 | def on_pre_build(config: Config): 17 | pass 18 | 19 | 20 | def on_files(files: Files, config: Config) -> Files: 21 | return remove_files(files) 22 | 23 | 24 | def remove_files(files: Files) -> Files: 25 | to_remove = [] 26 | for file in files: 27 | if file.src_path in {'plugins.py'}: 28 | to_remove.append(file) 29 | elif file.src_path.startswith('__pycache__/'): 30 | to_remove.append(file) 31 | 32 | for f in to_remove: 33 | files.remove(f) 34 | 35 | return files 36 | 37 | 38 | def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: 39 | markdown = remove_code_fence_attributes(markdown) 40 | return add_version(markdown, page) 41 | 42 | 43 | def add_version(markdown: str, page: Page) -> str: 44 | if page.file.src_uri == 'index.md': 45 | version_ref = os.getenv('GITHUB_REF') 46 | if version_ref and version_ref.startswith('refs/tags/'): 47 | version = re.sub('^refs/tags/', '', version_ref.lower()) 48 | url = f'https://github.com/pydantic/FastUI/releases/tag/{version}' 49 | version_str = f'Documentation for version: [{version}]({url})' 50 | elif sha := os.getenv('GITHUB_SHA'): 51 | sha = sha[:7] 52 | url = f'https://github.com/pydantic/FastUI/commit/{sha}' 53 | version_str = f'Documentation for development version: [{sha}]({url})' 54 | else: 55 | version_str = 'Documentation for development version' 56 | markdown = re.sub(r'{{ *version *}}', version_str, markdown) 57 | return markdown 58 | 59 | 60 | def remove_code_fence_attributes(markdown: str) -> str: 61 | """ 62 | There's no way to add attributes to code fences that works with both pycharm and mkdocs, hence we use 63 | `py key="value"` to provide attributes to pytest-examples, then remove those attributes here. 64 | 65 | https://youtrack.jetbrains.com/issue/IDEA-297873 & https://python-markdown.github.io/extensions/fenced_code_blocks/ 66 | """ 67 | 68 | def remove_attrs(match: Match[str]) -> str: 69 | suffix = re.sub( 70 | r' (?:test|lint|upgrade|group|requires|output|rewrite_assert)=".+?"', '', match.group(2), flags=re.M 71 | ) 72 | return f'{match.group(1)}{suffix}' 73 | 74 | return re.sub(r'^( *``` *py)(.*)', remove_attrs, markdown, flags=re.M) 75 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: FastUI 2 | site_description: Build web application user interfaces defined by declarative Python code. 3 | site_url: https://docs.pydantic.dev/fastui/ 4 | 5 | theme: 6 | name: 'material' 7 | palette: 8 | - media: "(prefers-color-scheme: light)" 9 | scheme: default 10 | primary: pink 11 | accent: pink 12 | toggle: 13 | icon: material/lightbulb-outline 14 | name: "Switch to dark mode" 15 | - media: "(prefers-color-scheme: dark)" 16 | scheme: slate 17 | primary: pink 18 | accent: pink 19 | toggle: 20 | icon: material/lightbulb 21 | name: "Switch to light mode" 22 | features: 23 | - content.code.annotate 24 | - content.tabs.link 25 | - content.code.copy 26 | - announce.dismiss 27 | - navigation.tabs 28 | - search.suggest 29 | - search.highlight 30 | logo: assets/logo-white.svg 31 | favicon: assets/favicon.png 32 | 33 | repo_name: pydantic/FastUI 34 | repo_url: https://github.com/pydantic/FastUI 35 | edit_uri: '' 36 | 37 | # https://www.mkdocs.org/user-guide/configuration/#validation 38 | validation: 39 | omitted_files: warn 40 | absolute_links: warn 41 | unrecognized_links: warn 42 | 43 | extra_css: 44 | - 'extra/tweaks.css' 45 | 46 | # TODO: add flarelytics support 47 | # extra_javascript: 48 | # - '/flarelytics/client.js' 49 | 50 | markdown_extensions: 51 | - toc: 52 | permalink: true 53 | - admonition 54 | - pymdownx.details 55 | - pymdownx.extra 56 | - pymdownx.superfences 57 | - pymdownx.highlight: 58 | anchor_linenums: true 59 | - pymdownx.inlinehilite 60 | - pymdownx.snippets 61 | - attr_list 62 | - md_in_html 63 | - pymdownx.emoji: 64 | emoji_index: !!python/name:material.extensions.emoji.twemoji 65 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 66 | watch: 67 | - src 68 | plugins: 69 | - search 70 | - mkdocstrings: 71 | handlers: 72 | python: 73 | paths: 74 | - src/python-fastui 75 | options: 76 | members_order: source 77 | separate_signature: true 78 | docstring_options: 79 | ignore_init_summary: true 80 | merge_init_into_class: true 81 | show_signature_annotations: true 82 | signature_crossrefs: true 83 | - mkdocs-simple-hooks: 84 | hooks: 85 | on_pre_build: 'docs.plugins:on_pre_build' 86 | on_files: 'docs.plugins:on_files' 87 | on_page_markdown: 'docs.plugins:on_page_markdown' 88 | nav: 89 | - Introduction: index.md 90 | - Guide: guide.md 91 | - API Documentation: 92 | - Python Components: api/python_components.md 93 | - TypeScript Components: api/typescript_components.md 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FastUI", 3 | "private": true, 4 | "type": "module", 5 | "workspaces": [ 6 | "src/*" 7 | ], 8 | "scripts": { 9 | "dev": "npm run --workspace=@pydantic/fastui-prebuilt dev", 10 | "build": "npm run --workspaces prepublishOnly", 11 | "typecheck": "npm run --workspaces typecheck", 12 | "lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0", 13 | "lint-fix": "npm run lint -- --fix", 14 | "prettier": "prettier --write", 15 | "format": "npm run prettier -- . && npm run lint-fix", 16 | "generate-json-schema": "./src/python-fastui/tests/react-fastui-json-schema.sh" 17 | }, 18 | "prettier": { 19 | "singleQuote": true, 20 | "semi": false, 21 | "trailingComma": "all", 22 | "tabWidth": 2, 23 | "printWidth": 119, 24 | "bracketSpacing": true 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^20.9.1", 28 | "@types/react": "^18.2.15", 29 | "@types/react-dom": "^18.2.7", 30 | "@typescript-eslint/eslint-plugin": "^6.0.0", 31 | "@typescript-eslint/parser": "^6.0.0", 32 | "eslint": "^8.53.0", 33 | "eslint-config-prettier": "^9.0.0", 34 | "eslint-config-standard": "^17.1.0", 35 | "eslint-plugin-react": "^7.33.2", 36 | "eslint-plugin-react-hooks": "^4.6.0", 37 | "eslint-plugin-react-refresh": "^0.4.4", 38 | "eslint-plugin-simple-import-sort": "^10.0.0", 39 | "json-schema-to-typescript": "^13.1.1", 40 | "typescript": "^5.0.2" 41 | }, 42 | "dependencies": { 43 | "prettier": "^3.2.5", 44 | "typedoc": "^0.25.13" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # this is not the used for the python package "fastui", 2 | # for that see packages/python-fastui/pyproject.toml 3 | 4 | [tool.ruff] 5 | line-length = 120 6 | extend-select = ["Q", "RUF100", "UP", "I"] 7 | flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} 8 | format.quote-style="single" 9 | target-version = "py38" 10 | 11 | [tool.pyright] 12 | include = ["src/python-fastui/fastui"] 13 | 14 | [tool.pytest.ini_options] 15 | testpaths = [ 16 | "src/python-fastui/tests", 17 | "demo/tests.py", 18 | ] 19 | xfail_strict = true 20 | filterwarnings = ["error"] 21 | asyncio_mode = "auto" 22 | 23 | [tool.coverage.run] 24 | source = ["src/python-fastui/fastui"] 25 | omit = [ 26 | "src/python-fastui/fastui/__main__.py", 27 | "src/python-fastui/fastui/generate_typescript.py", 28 | ] 29 | 30 | [tool.coverage.report] 31 | precision = 2 32 | exclude_lines = [ 33 | 'pragma: no cover', 34 | 'raise NotImplementedError', 35 | 'if TYPE_CHECKING:', 36 | 'if typing.TYPE_CHECKING:', 37 | '@overload', 38 | '@typing.overload', 39 | '\(Protocol\):$', 40 | ] 41 | -------------------------------------------------------------------------------- /requirements/docs.in: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | mkdocs-simple-hooks 4 | mkdocstrings[python] 5 | mkdocs-redirects 6 | mkdocs-material-extensions 7 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements/docs.txt requirements/docs.in 6 | # 7 | babel==2.14.0 8 | # via mkdocs-material 9 | certifi==2024.2.2 10 | # via requests 11 | charset-normalizer==3.3.2 12 | # via requests 13 | click==8.1.7 14 | # via 15 | # mkdocs 16 | # mkdocstrings 17 | colorama==0.4.6 18 | # via 19 | # griffe 20 | # mkdocs-material 21 | ghp-import==2.1.0 22 | # via mkdocs 23 | griffe==0.44.0 24 | # via mkdocstrings-python 25 | idna==3.7 26 | # via requests 27 | jinja2==3.1.3 28 | # via 29 | # mkdocs 30 | # mkdocs-material 31 | # mkdocstrings 32 | markdown==3.6 33 | # via 34 | # mkdocs 35 | # mkdocs-autorefs 36 | # mkdocs-material 37 | # mkdocstrings 38 | # pymdown-extensions 39 | markupsafe==2.1.5 40 | # via 41 | # jinja2 42 | # mkdocs 43 | # mkdocs-autorefs 44 | # mkdocstrings 45 | mergedeep==1.3.4 46 | # via mkdocs 47 | mkdocs==1.5.3 48 | # via 49 | # -r requirements/docs.in 50 | # mkdocs-autorefs 51 | # mkdocs-material 52 | # mkdocs-redirects 53 | # mkdocs-simple-hooks 54 | # mkdocstrings 55 | mkdocs-autorefs==1.0.1 56 | # via mkdocstrings 57 | mkdocs-material==9.5.18 58 | # via -r requirements/docs.in 59 | mkdocs-material-extensions==1.3.1 60 | # via 61 | # -r requirements/docs.in 62 | # mkdocs-material 63 | mkdocs-redirects==1.2.1 64 | # via -r requirements/docs.in 65 | mkdocs-simple-hooks==0.1.5 66 | # via -r requirements/docs.in 67 | mkdocstrings[python]==0.24.3 68 | # via 69 | # -r requirements/docs.in 70 | # mkdocstrings-python 71 | mkdocstrings-python==1.10.0 72 | # via mkdocstrings 73 | packaging==24.0 74 | # via mkdocs 75 | paginate==0.5.6 76 | # via mkdocs-material 77 | pathspec==0.12.1 78 | # via mkdocs 79 | platformdirs==4.2.0 80 | # via 81 | # mkdocs 82 | # mkdocstrings 83 | pygments==2.17.2 84 | # via mkdocs-material 85 | pymdown-extensions==10.8 86 | # via 87 | # mkdocs-material 88 | # mkdocstrings 89 | python-dateutil==2.9.0.post0 90 | # via ghp-import 91 | pyyaml==6.0.1 92 | # via 93 | # mkdocs 94 | # pymdown-extensions 95 | # pyyaml-env-tag 96 | pyyaml-env-tag==0.1 97 | # via mkdocs 98 | regex==2024.4.16 99 | # via mkdocs-material 100 | requests==2.31.0 101 | # via mkdocs-material 102 | six==1.16.0 103 | # via python-dateutil 104 | urllib3==2.2.1 105 | # via requests 106 | watchdog==4.0.0 107 | # via mkdocs 108 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/FastUI/3df5edd9b8c1d83efcad29614e69cf8d5266b9f3/screenshot.png -------------------------------------------------------------------------------- /src/npm-fastui-bootstrap/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 to present Pydantic Services inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/npm-fastui-bootstrap/README.md: -------------------------------------------------------------------------------- 1 | # FastUI Bootstrap 2 | 3 | Bootstrap components for [FastUI](https://github.com/pydantic/FastUI). 4 | -------------------------------------------------------------------------------- /src/npm-fastui-bootstrap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pydantic/fastui-bootstrap", 3 | "version": "0.0.24", 4 | "description": "Bootstrap renderer for FastUI", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "author": "Samuel Colvin", 8 | "license": "MIT", 9 | "homepage": "https://github.com/pydantic/fastui", 10 | "private": false, 11 | "keywords": [ 12 | "fastui", 13 | "bootstrap", 14 | "jsx", 15 | "typescript", 16 | "react", 17 | "fastapi" 18 | ], 19 | "scripts": { 20 | "prepublishOnly": "rm -rf dist && tsc", 21 | "typecheck": "tsc --noEmit", 22 | "typewatch": "tsc --noEmit --watch" 23 | }, 24 | "dependencies": { 25 | "bootstrap": "^5.3.2", 26 | "react": "^18.2.0", 27 | "react-bootstrap": "^2.9.1", 28 | "react-dom": "^18.2.0", 29 | "sass": "^1.69.5" 30 | }, 31 | "peerDependencies": { 32 | "@pydantic/fastui": "0.0.24" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/npm-fastui-bootstrap/src/footer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { components, models, useClassName } from 'fastui' 3 | 4 | export const Footer: FC = (props) => { 5 | const links = props.links.map((link) => { 6 | link.mode = link.mode || 'footer' 7 | return link 8 | }) 9 | const extraProp = useClassName(props, { el: 'extra' }) 10 | return ( 11 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/npm-fastui-bootstrap/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { pathMatch } from 'fastui' 2 | 3 | import type { ClassNameGenerator, CustomRender, models } from 'fastui' 4 | 5 | import { Modal } from './modal' 6 | import { Navbar } from './navbar' 7 | import { Pagination } from './pagination' 8 | import { Footer } from './footer' 9 | import { Toast } from './toast' 10 | 11 | export const customRender: CustomRender = (props) => { 12 | const { type } = props 13 | switch (type) { 14 | case 'Navbar': 15 | return () => 16 | case 'Footer': 17 | return () =>