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) + [![CI](https://github.com/pydantic/FastUI/actions/workflows/ci.yml/badge.svg)](https://github.com/pydantic/FastUI/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) [![pypi](https://img.shields.io/pypi/v/fastui.svg)](https://pypi.python.org/pypi/fastui) [![versions](https://img.shields.io/pypi/pyversions/fastui.svg)](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: + +![screenshot](https://raw.githubusercontent.com/pydantic/FastUI/main/screenshot.png) + +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 () =>