Better demo (#17)

This commit is contained in:
Samuel Colvin
2023-12-01 17:03:41 +00:00
committed by GitHub
parent 6869b0b490
commit dbdc9aef9c
23 changed files with 594 additions and 370 deletions

View File

@@ -119,14 +119,14 @@ Of course, that's a very simple application, the [full demo](https://fastui-demo
### Components
FastUI already defines the following components:
FastUI already defines the following components, all are shown in the [demo app](https://fastui-demo.onrender.com):
- `Text` - renders a string
- `Paragraph` - renders a string as a paragraph
- `PageTitle` - renders nothing, sets the HTML page title
- `Div` - renders a `<div>` with arbitrary components inside
- `Page` - a container for components, [example](https://fastui-demo.onrender.com)
- `Heading` - renders a heading `<h1>` to `<h6>`, [example](https://fastui-demo.onrender.com)
- `Page` - a container for components
- `Heading` - renders a heading `<h1>` to `<h6>`
- `Markdown` - renders markdown, [example](https://fastui-demo.onrender.com)
- `Code` - renders code with highlighting in a `<pre>`
- `Button` - renders a `<button>`

8
package-lock.json generated
View File

@@ -6602,7 +6602,7 @@
},
"packages/fastui": {
"name": "@pydantic/fastui",
"version": "0.0.8",
"version": "0.0.9",
"license": "MIT",
"dependencies": {
"react": "^18.2.0",
@@ -6618,7 +6618,7 @@
},
"packages/fastui-bootstrap": {
"name": "@pydantic/fastui-bootstrap",
"version": "0.0.8",
"version": "0.0.9",
"license": "MIT",
"dependencies": {
"bootstrap": "^5.3.2",
@@ -6628,12 +6628,12 @@
"sass": "^1.69.5"
},
"peerDependencies": {
"@pydantic/fastui": "0.0.8"
"@pydantic/fastui": "0.0.9"
}
},
"packages/fastui-prebuilt": {
"name": "@pydantic/fastui-prebuilt",
"version": "0.0.8",
"version": "0.0.9",
"license": "MIT",
"devDependencies": {
"@vitejs/plugin-react-swc": "^3.3.2",

View File

@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui-bootstrap",
"version": "0.0.8",
"version": "0.0.9",
"description": "Boostrap renderer for FastUI",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -29,6 +29,6 @@
"sass": "^1.69.5"
},
"peerDependencies": {
"@pydantic/fastui": "0.0.8"
"@pydantic/fastui": "0.0.9"
}
}

View File

@@ -1,6 +1,6 @@
import { pathMatch } from 'fastui'
import type { components, ClassNameGenerator, CustomRender, ClassName } from 'fastui'
import type { ClassNameGenerator, CustomRender, ClassName } from 'fastui'
import { Modal } from './modal'
import { Navbar } from './navbar'
@@ -9,8 +9,6 @@ import { Pagination } from './pagination'
export const customRender: CustomRender = (props) => {
const { type } = props
switch (type) {
case 'DisplayPrimitive':
return displayPrimitiveRender(props)
case 'Navbar':
return () => <Navbar {...props} />
case 'Modal':
@@ -20,13 +18,6 @@ export const customRender: CustomRender = (props) => {
}
}
function displayPrimitiveRender(props: components.DisplayPrimitiveProps) {
const { value } = props
if (typeof value === 'boolean') {
return () => <>{value ? '👍' : '👎'}</>
}
}
export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subElement }): ClassName => {
const { type } = props
switch (type) {

View File

@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui-prebuilt",
"version": "0.0.8",
"version": "0.0.9",
"description": "Pre-built files for FastUI",
"main": "dist/index.html",
"type": "module",

View File

@@ -1,13 +1,31 @@
$primary: black;
$link-color: #0d6efd; // bootstrap primary
$link-color: #0d6efd; // bootstrap primary
@import 'bootstrap/scss/bootstrap';
:root {
--bs-font-sans-serif: 'IBM Plex Sans', sans-serif;
--bs-code-color: rgb(31, 35, 40);
//
}
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;1,400&display=swap');
body {
margin-bottom: 60px;
}
.bg-body {
--bs-bg-opacity: 0.6;
backdrop-filter: blur(8px);
}
.top-offset {
margin-top: 70px;
h1, h2, h3, h4, h5, h6 {
scroll-margin-top: 60px;
}
}
.transition-overlay {
position: fixed;
top: 0;
@@ -27,13 +45,13 @@ $link-color: #0d6efd; // bootstrap primary
}
}
.bg-body {
--bs-bg-opacity: 0.6;
backdrop-filter: blur(8px);
}
.top-offset {
margin-top: 80px;
.fastui-markdown code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
white-space: break-spaces;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
}
// custom spinner from https://cssloaders.github.io/

View File

@@ -1,6 +1,6 @@
{
"name": "@pydantic/fastui",
"version": "0.0.8",
"version": "0.0.9",
"description": "Build better UIs faster.",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -26,7 +26,11 @@ const MarkdownComp: FC<MarkdownProps> = (props) => {
}
return (
<Markdown className={useClassName(props)} remarkPlugins={[remarkGfm]} components={components}>
<Markdown
className={useClassName(props, { dft: 'fastui-markdown' })}
remarkPlugins={[remarkGfm]}
components={components}
>
{text}
</Markdown>
)
@@ -75,7 +79,24 @@ interface MarkdownCodeProps {
const MarkdownCode: FC<MarkdownCodeProps> = ({ children, className, codeStyle }) => {
const match = /language-(\w+)/.exec(className || '')
const language = match ? match[1] : undefined
if (match) {
return (
<MarkdownCodeHighlight codeStyle={codeStyle} language={match[1]}>
{children}
</MarkdownCodeHighlight>
)
} else {
return <code className={className}>{children}</code>
}
}
interface MarkdownCodeHighlightProps {
children: ReactNode
language?: string
codeStyle?: string
}
const MarkdownCodeHighlight: FC<MarkdownCodeHighlightProps> = ({ children, codeStyle, language }) => {
const codeProps: CodeProps = {
type: 'Code',
text: String(children).replace(/\n$/, ''),

View File

@@ -62,6 +62,16 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path
promise.then(([status, data]) => {
if (status === 200) {
setComponentProps(data as FastProps[])
// if there's a fragment, scroll to that ID once the page is loaded
const fragment = getFragment(path)
if (fragment) {
setTimeout(() => {
const element = document.getElementById(fragment)
if (element) {
element.scrollIntoView()
}
}, 50)
}
} else {
setNotFoundUrl(url)
}
@@ -71,7 +81,7 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path
return () => {
promise.then(() => null)
}
}, [url, request, devReload])
}, [url, path, request, devReload])
useEffect(() => {
setNotFoundUrl(undefined)
@@ -129,3 +139,10 @@ function useServerUrl(path: string): string {
return rootUrl + requestPath
}
}
function getFragment(path: string): string | undefined {
const index = path.indexOf('#')
if (index !== -1) {
return path.slice(index + 1)
}
}

View File

@@ -1,6 +1,7 @@
import { FC } from 'react'
import { ClassName, useClassName } from '../hooks/className'
import { slugify } from '../tools'
export interface HeadingProps {
type: 'Heading'
@@ -13,7 +14,7 @@ export interface HeadingProps {
export const HeadingComp: FC<HeadingProps> = (props) => {
const { level, text, htmlId } = props
const HeadingComponent = getComponent(level)
return <HeadingComponent text={text} id={htmlId} className={useClassName(props)} />
return <HeadingComponent text={text} id={htmlId || slugify(text)} className={useClassName(props)} />
}
function getComponent(level: 1 | 2 | 3 | 4 | 5 | 6): FC<{ text: string; id?: string; className?: string }> {

View File

@@ -104,8 +104,12 @@ export function LocationProvider({ children }: { children: ReactNode }) {
fullPath,
goto: useCallback(
(newPath) => {
const path = pushPath(newPath)
fireLoadEvent({ path })
if (newPath.startsWith('http')) {
window.location.href = newPath
} else {
const path = pushPath(newPath)
fireLoadEvent({ path })
}
},
[pushPath],
),

View File

@@ -168,3 +168,12 @@ export async function sleep(ms: number): Promise<void> {
// usage `as_title('what_ever') > 'What Ever'`
export const asTitle = (s: string): string => s.replace(/[_-]/g, ' ').replace(/(_|\b)\w/g, (l) => l.toUpperCase())
export const slugify = (s: string): string =>
s
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/[^\w-]+/g, '') // Remove all non-word characters
.replace(/--+/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, '') // Trim - from end of text

View File

@@ -34,7 +34,7 @@ classifiers = [
"Framework :: FastAPI",
]
requires-python = ">=3.8"
dependencies = ["pydantic>=2.5.2"]
dependencies = ["pydantic[email]>=2.5.2"]
dynamic = ["version"]
[project.optional-dependencies]

View File

@@ -9,6 +9,8 @@ from fastui import prebuilt_html
from fastui.dev import dev_fastapi_app
from httpx import AsyncClient
from .components_list import router as components_router
from .forms import router as forms_router
from .main import router as main_router
from .tables import router as table_router
@@ -27,7 +29,9 @@ if frontend_reload:
else:
app = FastAPI(lifespan=lifespan)
app.include_router(components_router, prefix='/api/components')
app.include_router(table_router, prefix='/api/table')
app.include_router(forms_router, prefix='/api/forms')
app.include_router(main_router, prefix='/api')

View File

@@ -0,0 +1,188 @@
from __future__ import annotations as _annotations
import asyncio
from datetime import datetime
from typing import AsyncIterable
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import GoToEvent, PageEvent
from .shared import demo_page
router = APIRouter()
def panel(*components: AnyComponent) -> AnyComponent:
return c.Div(class_name='col border rounded m-1 p-2 pb-3', components=list(components))
@router.get('', response_model=FastUI, response_model_exclude_none=True)
def components_view() -> list[AnyComponent]:
return demo_page(
c.Div(
components=[
c.Heading(text='Text', level=2),
c.Text(text='This is a text component.'),
]
),
c.Div(
components=[
c.Heading(text='Paragraph', level=2),
c.Paragraph(text='This is a paragraph component.'),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Heading', level=2),
c.Heading(text='This is an H3', level=3),
c.Heading(text='This is an H4', level=4),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Code', level=2),
c.Code(
language='python',
text="""\
from pydantic import BaseModel
class Delivery(BaseModel):
dimensions: tuple[int, int]
m = Delivery(dimensions=['10', '20'])
print(m.dimensions)
#> (10, 20)
""",
),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Link List', level=2),
c.Markdown(
text=(
'This is a simple unstyled list of links, '
'LinkList is also used in `Navbar` and `Pagination`.'
)
),
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Internal Link - the the home page')],
on_click=GoToEvent(url='/'),
),
c.Link(
components=[c.Text(text='Pydantic (External link)')],
on_click=GoToEvent(url='https://pydantic.dev'),
),
],
),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
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.Modal(
title='Static Modal',
body=[c.Paragraph(text='This is some static content that was set when the modal was defined.')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='static-modal', clear=True)),
],
open_trigger=PageEvent(name='static-modal'),
),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Dynamic Modal', level=2),
c.Markdown(
text=(
'The button below will open a modal with content loaded from the server when '
"it's opened using `ServerLoad`."
)
),
c.Button(text='Show Dynamic Modal', on_click=PageEvent(name='dynamic-modal')),
c.Modal(
title='Dynamic Modal',
body=[c.ServerLoad(path='/components/dynamic-content')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='dynamic-modal', clear=True)),
],
open_trigger=PageEvent(name='dynamic-modal'),
),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Server Load', level=2),
c.Paragraph(text='Even simpler example of server load, replacing existing content.'),
c.Button(text='Load Content from Server', on_click=PageEvent(name='server-load')),
c.Div(
components=[
c.ServerLoad(
path='/components/dynamic-content',
load_trigger=PageEvent(name='server-load'),
components=[c.Text(text='before')],
),
],
class_name='py-2',
),
],
class_name='border-top mt-3 pt-1',
),
c.Div(
components=[
c.Heading(text='Server Load SSE', level=2),
c.Markdown(text='`ServerLoad` can also be used to load content from an SSE stream.'),
c.Button(text='Load SSE content', on_click=PageEvent(name='server-load-sse')),
c.Div(
components=[
c.ServerLoad(
path='/components/sse',
sse=True,
load_trigger=PageEvent(name='server-load-sse'),
components=[c.Text(text='before')],
),
],
class_name='my-2 p-2 border rounded',
),
],
class_name='border-top mt-3 pt-1',
),
title='Components',
)
@router.get('/dynamic-content', response_model=FastUI, response_model_exclude_none=True)
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.')]
async def sse_generator() -> AsyncIterable[str]:
while True:
d = datetime.now()
m = FastUI(
root=[
c.Div(components=[c.Text(text=f'Time {d:%H:%M:%S}')], class_name='font-monospace'),
c.Paragraph(text='This content is updated every second using an SSE stream.'),
]
)
yield f'data: {m.model_dump_json(by_alias=True)}\n\n'
await asyncio.sleep(1)
@router.get('/sse')
async def sse_experiment() -> StreamingResponse:
return StreamingResponse(sse_generator(), media_type='text/event-stream')

175
python/demo/forms.py Normal file
View File

@@ -0,0 +1,175 @@
from __future__ import annotations as _annotations
from collections import defaultdict
from datetime import date
from enum import StrEnum
from typing import Annotated, Literal, TypeAlias
from fastapi import APIRouter, Request, UploadFile
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
from httpx import AsyncClient
from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError
from .shared import demo_page
router = APIRouter()
@router.get('/search', response_model=SelectSearchResponse)
async def search_view(request: Request, q: str) -> SelectSearchResponse:
path_ends = f'name/{q}' if q else 'all'
client: AsyncClient = request.app.state.httpx_client
r = await client.get(f'https://restcountries.com/v3.1/{path_ends}')
if r.status_code == 404:
options = []
else:
r.raise_for_status()
data = r.json()
if path_ends == 'all':
# if we got all, filter to the 20 most populous countries
data.sort(key=lambda x: x['population'], reverse=True)
data = data[0:20]
data.sort(key=lambda x: x['name']['common'])
regions = defaultdict(list)
for co in data:
regions[co['region']].append({'value': co['cca3'], 'label': co['name']['common']})
options = [{'label': k, 'options': v} for k, v in regions.items()]
return SelectSearchResponse(options=options)
FormKind: TypeAlias = Literal['login', 'select', 'big']
@router.get('/{kind}', response_model=FastUI, response_model_exclude_none=True)
def forms_view(kind: FormKind) -> list[AnyComponent]:
return demo_page(
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Login Form')],
on_click=PageEvent(name='change-form', push_path='/forms/login', context={'kind': 'login'}),
active='/forms/login',
),
c.Link(
components=[c.Text(text='Select Form')],
on_click=PageEvent(name='change-form', push_path='/forms/select', context={'kind': 'select'}),
active='/forms/select',
),
c.Link(
components=[c.Text(text='Big Form')],
on_click=PageEvent(name='change-form', push_path='/forms/big', context={'kind': 'big'}),
active='/forms/big',
),
],
mode='tabs',
class_name='+ mb-4',
),
c.ServerLoad(
path='/forms/content/{kind}',
load_trigger=PageEvent(name='change-form'),
components=form_content(kind),
),
title='Forms',
)
@router.get('/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_content(kind: FormKind):
match kind:
case 'login':
return [
c.Heading(text='Login Form', level=2),
c.Paragraph(text='Simple login form with email and password.'),
c.ModelForm[LoginForm](
submit_url='/api/forms/login',
success_event=PageEvent(name='form_success'),
),
]
case 'select':
return [
c.Heading(text='Select Form', level=2),
c.Paragraph(text='Form showing different ways of doing select.'),
c.ModelForm[SelectForm](
submit_url='/api/forms/select',
success_event=PageEvent(name='form_success'),
),
]
case 'big':
return [
c.Heading(text='Large Form', level=2),
c.Paragraph(text='Form with a lot of fields.'),
c.ModelForm[BigModel](
submit_url='/api/forms/big',
success_event=PageEvent(name='form_success'),
),
]
case _:
raise ValueError(f'Invalid kind {kind!r}')
class LoginForm(BaseModel):
email: EmailStr = Field(title='Email Address', description="Try 'x@y' to trigger server side validation")
password: SecretStr
@router.post('/login')
async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> FormResponse:
# print(form)
return FormResponse(event=GoToEvent(url='/'))
class ToolEnum(StrEnum):
hammer = 'hammer'
screwdriver = 'screwdriver'
saw = 'saw'
claw_hammer = 'claw_hammer'
class SelectForm(BaseModel):
select_single: ToolEnum = Field(title='Select Single')
select_multiple: list[ToolEnum] = Field(title='Select Multiple')
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'})
@router.post('/select')
async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]) -> FormResponse:
# print(form)
return FormResponse(event=GoToEvent(url='/'))
class SizeModel(BaseModel):
width: int = Field(description='This is a field of a nested model')
height: int = Field(description='This is a field of a nested model')
class BigModel(BaseModel):
name: str | None = Field(
None, description='This field is not required, it must start with a capital letter if provided'
)
profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] = Field(
description='Upload a profile picture, must not be more than 16kb'
)
profile_pics: Annotated[list[UploadFile], FormFile(accept='image/*')] | None = Field(
None, description='Upload multiple images'
)
dob: date = Field(title='Date of Birth', description='Your date of birth, this is required hence bold')
size: SizeModel
@field_validator('name')
def name_validator(cls, v: str | None) -> str:
if v and v[0].islower():
raise PydanticCustomError('lower', 'Name must start with a capital letter')
return v
@router.post('/big')
async def big_form_post(form: Annotated[BigModel, fastui_form(BigModel)]) -> FormResponse:
# print(form)
return FormResponse(event=GoToEvent(url='/'))

View File

@@ -1,253 +1,39 @@
from __future__ import annotations as _annotations
import asyncio
from collections import defaultdict
from datetime import datetime
from enum import StrEnum
from typing import Annotated, AsyncIterable, Literal
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import StreamingResponse
from fastapi import APIRouter
from fastui import AnyComponent, FastUI
from fastui import components as c
from fastui.events import GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
from httpx import AsyncClient
from pydantic import BaseModel, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError
from .shared import navbar
from .shared import demo_page
router = APIRouter()
def panel(*components: AnyComponent) -> AnyComponent:
return c.Div(class_name='col border rounded m-1 p-2 pb-3', components=list(components))
@router.get('/', response_model=FastUI, response_model_exclude_none=True)
def api_index() -> list[AnyComponent]:
return [
c.PageTitle(text='FastUI Demo'),
navbar(),
c.Page(
components=[
c.Heading(text='Modal and Flex examples'),
c.Paragraph(text='Below is an example of a flex container with 3 panels.'),
c.Markdown(
text="""\
This is some **Markdown**, link to [table](/table/cities).
# language=markdown
markdown = """\
This site providers a demo of [FastUI](https://github.com/samuelcolvin/FastUI).
```python
x = 1
y = 2
assert x + y == 3
```
The following components are demonstrated:
* `Markdown` — that's me :-)
* `Text`— example [here](/components#text)
* `Paragraph` — example [here](/components#paragraph)
* `PageTitle` — you'll see the title in the browser tab change when you navigate through the site
* `Heading` — example [here](/components#heading)
* `Code` — example [here](/components#code)
* `Button` — example [here](/components#button-and-modal)
* `Link` — example [here](/components#link-list)
* `LinkList` — example [here](/components#link-list)
* `Navbar` — see the top 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)
* `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)
"""
),
c.Div(
class_name='row',
components=[
panel(
c.Heading(text='Panel 1', level=3),
c.Paragraph(text='This is a div with a border and rounded corners.'),
),
panel(
c.Heading(text='Panel 2', level=3),
c.Paragraph(text='Click the link below to open a modal with content included directly'),
c.Button(text='Show Static Modal', on_click=PageEvent(name='static-modal')),
),
panel(
c.Heading(text='Panel 3', level=3),
c.Paragraph(
text=(
'Click the link below to open a modal with content loaded from the '
'server when the modal is opened.'
)
),
c.Button(text='Show Dynamic Modal', on_click=PageEvent(name='dynamic-modal')),
),
],
),
c.ServerLoad(path='/sse', sse=True),
c.Modal(
title='Static Modal',
body=[c.Paragraph(text='This is some static content in a modal.')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='static-modal', clear=True)),
],
open_trigger=PageEvent(name='static-modal'),
),
c.Modal(
title='Dynamic Modal',
body=[c.ServerLoad(path='/modal')],
footer=[
c.Button(text='Close', on_click=PageEvent(name='dynamic-modal', clear=True)),
],
open_trigger=PageEvent(name='dynamic-modal'),
),
],
),
]
@router.get('/modal', response_model=FastUI, response_model_exclude_none=True)
async def modal_view() -> list[AnyComponent]:
await asyncio.sleep(0.5)
return [c.Text(text='Modal Content Dynamic')]
class NestedFormModel(BaseModel):
# x: int
# profile_view: HttpUrl
profile_view: str
class ToolEnum(StrEnum):
hammer = 'hammer'
screwdriver = 'screwdriver'
saw = 'saw'
claw_hammer = 'claw_hammer'
class MyFormModel(BaseModel):
name: str = Field(default='foobar', title='Name', min_length=3, description='Your name')
# tool: ToolEnum = Field(json_schema_extra={'enum_labels': {'hammer': 'Big Hammer'}})
task: Literal['build', 'destroy'] | None = 'build'
tasks: set[Literal['build', 'destroy']]
profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)]
# profile_pics: Annotated[list[UploadFile], FormFile(accept='image/*', max_size=400)]
# binary: bytes
# dob: date = Field(title='Date of Birth', description='Your date of birth')
# weight: typing.Annotated[int, annotated_types.Gt(0)]
# size: PositiveInt = None
# enabled: bool = False
# nested: NestedFormModel
password: SecretStr
search: str = Field(json_schema_extra={'search_url': '/api/search'})
searches: list[str] = Field(json_schema_extra={'search_url': '/api/search'})
@field_validator('name')
def name_validator(cls, v: str) -> str:
if v[0].islower():
raise PydanticCustomError('lower', 'Name must start with a capital letter')
return v
@router.get('/search', response_model=SelectSearchResponse)
async def search_view(request: Request, q: str) -> SelectSearchResponse:
path_ends = f'name/{q}' if q else 'all'
client: AsyncClient = request.app.state.httpx_client
r = await client.get(f'https://restcountries.com/v3.1/{path_ends}')
if r.status_code == 404:
options = []
else:
r.raise_for_status()
data = r.json()
if path_ends == 'all':
# if we got all, filter to the 20 most populous countries
data.sort(key=lambda x: x['population'], reverse=True)
data = data[0:20]
data.sort(key=lambda x: x['name']['common'])
regions = defaultdict(list)
for co in data:
regions[co['region']].append({'value': co['cca3'], 'label': co['name']['common']})
options = [{'label': k, 'options': v} for k, v in regions.items()]
return SelectSearchResponse(options=options)
@router.get('/form/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]:
return [
navbar(),
c.PageTitle(text='FastUI Demo - Form Examples'),
c.Page(
components=[
c.Heading(text='Form'),
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Form One')],
on_click=PageEvent(name='change-form', push_path='/form/one', context={'kind': 'one'}),
active='/form/one',
),
c.Link(
components=[c.Text(text='Form Two')],
on_click=PageEvent(name='change-form', push_path='/form/two', context={'kind': 'two'}),
active='/form/two',
),
c.Link(
components=[c.Text(text='Form Three')],
on_click=PageEvent(name='change-form', push_path='/form/three', context={'kind': 'three'}),
active='/form/three',
),
],
mode='tabs',
),
c.ServerLoad(
path='/form/content/{kind}',
load_trigger=PageEvent(name='change-form'),
components=form_content(kind),
),
]
),
]
@router.get('/form/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_content(kind: Literal['one', 'two', 'three']):
match kind:
case 'one':
return [
c.Heading(text='Form One', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
# footer=[
# c.Button(text='Cancel', on_click=GoToEvent(url='/')),
# c.Button(text='Submit', html_type='submit'),
# ]
),
]
case 'two':
return [
c.Heading(text='Form Two', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
),
]
case 'three':
return [
c.Heading(text='Form Three', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
success_event=PageEvent(name='form_success'),
),
]
case _:
raise ValueError(f'Invalid kind {kind!r}')
@router.post('/form')
async def form_post(form: Annotated[MyFormModel, fastui_form(MyFormModel)]) -> FormResponse:
return FormResponse(event=GoToEvent(url='/'))
async def sse_generator() -> AsyncIterable[str]:
while True:
d = datetime.now()
m = FastUI(root=[c.Div(components=[c.Text(text=f'Time {d:%H:%M:%S}')], class_name='font-monospace')])
yield f'data: {m.model_dump_json(by_alias=True)}\n\n'
await asyncio.sleep(1)
@router.get('/sse')
async def sse_experiment() -> StreamingResponse:
return StreamingResponse(sse_generator(), media_type='text/event-stream')
return demo_page(c.Markdown(text=markdown))
@router.get('/{path:path}', status_code=404)

View File

@@ -5,14 +5,34 @@ from fastui import components as c
from fastui.events import GoToEvent
def navbar() -> AnyComponent:
return c.Navbar(
title='FastUI Demo',
links=[
c.Link(components=[c.Text(text='Home')], on_click=GoToEvent(url='/'), active='/'),
c.Link(
components=[c.Text(text='Tables')], on_click=GoToEvent(url='/table/cities'), active='startswith:/table'
),
c.Link(components=[c.Text(text='Forms')], on_click=GoToEvent(url='/form/one'), active='startswith:/form'),
],
)
def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyComponent]:
return [
c.PageTitle(text=f'FastUI Demo{title}' if title else 'FastUI Demo'),
c.Navbar(
title='FastUI Demo',
title_event=GoToEvent(url='/'),
links=[
c.Link(
components=[c.Text(text='Components')],
on_click=GoToEvent(url='/components'),
active='startswith:/components',
),
c.Link(
components=[c.Text(text='Tables')],
on_click=GoToEvent(url='/table/cities'),
active='startswith:/table',
),
c.Link(
components=[c.Text(text='Forms')],
on_click=GoToEvent(url='/forms/login'),
active='startswith:/forms',
),
],
),
c.Page(
components=[
*((c.Heading(text=title),) if title else ()),
*components,
],
),
]

View File

@@ -10,34 +10,11 @@ from fastui.components.display import DisplayLookup, DisplayMode
from fastui.events import BackEvent, GoToEvent
from pydantic import BaseModel, Field, TypeAdapter
from .shared import navbar
from .shared import demo_page
router = APIRouter()
def heading(title: str) -> list[AnyComponent]:
return [
c.Heading(text='Tables'),
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Cities')],
on_click=GoToEvent(url='/table/cities'),
active='startswith:/table/cities',
),
c.Link(
components=[c.Text(text='Users')],
on_click=GoToEvent(url='/table/users'),
active='startswith:/table/users',
),
],
mode='tabs',
class_name='+ mb-4',
),
c.Heading(text=title, level=3),
]
class City(BaseModel):
id: int = Field(title='ID')
city: str = Field(title='Name')
@@ -67,7 +44,7 @@ def cities_lookup() -> dict[id, City]:
class FilterForm(pydantic.BaseModel):
country: str = Field(json_schema_extra={'search_url': '/api/search', 'placeholder': 'Filter by Country...'})
country: str = Field(json_schema_extra={'search_url': '/api/forms/search', 'placeholder': 'Filter by Country...'})
@router.get('/cities', response_model=FastUI, response_model_exclude_none=True)
@@ -79,47 +56,37 @@ def cities_view(page: int = 1, country: str | None = None) -> list[AnyComponent]
cities = [city for city in cities if city.iso3 == country]
country_name = cities[0].country if cities else country
filter_form_initial['country'] = {'value': country, 'label': country_name}
return [
navbar(),
c.PageTitle(text='FastUI Demo - Table'),
c.Page(
components=[
*heading('Cities'),
c.ModelForm[FilterForm](
submit_url='.',
initial=filter_form_initial,
method='GOTO',
submit_on_change=True,
display_mode='inline',
),
c.Table[City](
data=cities[(page - 1) * page_size : page * page_size],
columns=[
DisplayLookup(field='city', on_click=GoToEvent(url='./{id}'), table_width_percent=33),
DisplayLookup(field='country', table_width_percent=33),
DisplayLookup(field='population', table_width_percent=33),
],
),
c.Pagination(page=page, page_size=page_size, total=len(cities)),
]
return demo_page(
*tabs(),
c.ModelForm[FilterForm](
submit_url='.',
initial=filter_form_initial,
method='GOTO',
submit_on_change=True,
display_mode='inline',
),
]
c.Table[City](
data=cities[(page - 1) * page_size : page * page_size],
columns=[
DisplayLookup(field='city', on_click=GoToEvent(url='./{id}'), table_width_percent=33),
DisplayLookup(field='country', table_width_percent=33),
DisplayLookup(field='population', table_width_percent=33),
],
),
c.Pagination(page=page, page_size=page_size, total=len(cities)),
title='Cities',
)
@router.get('/cities/{city_id}', response_model=FastUI, response_model_exclude_none=True)
def city_view(city_id: int) -> list[AnyComponent]:
city = cities_lookup()[city_id]
return [
navbar(),
c.PageTitle(text='FastUI Demo - Table'),
c.Page(
components=[
*heading(city.city),
c.Link(components=[c.Text(text='Back')], on_click=BackEvent()),
c.Details(data=city),
]
),
]
return demo_page(
*tabs(),
c.Link(components=[c.Text(text='Back')], on_click=BackEvent()),
c.Details(data=city),
title=city.city,
)
class MyTableRow(BaseModel):
@@ -131,24 +98,40 @@ class MyTableRow(BaseModel):
@router.get('/users', response_model=FastUI, response_model_exclude_none=True)
def users_view() -> list[AnyComponent]:
return demo_page(
*tabs(),
c.Table[MyTableRow](
data=[
MyTableRow(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
MyTableRow(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
MyTableRow(id=3, name='Jack', dob=date(1992, 1, 1)),
],
columns=[
DisplayLookup(field='name', on_click=GoToEvent(url='/more/{id}/')),
DisplayLookup(field='dob', mode=DisplayMode.date),
DisplayLookup(field='enabled'),
],
),
title='Users',
)
def tabs() -> list[AnyComponent]:
return [
navbar(),
c.PageTitle(text='FastUI Demo - Table'),
c.Page(
components=[
*heading('Users'),
c.Table[MyTableRow](
data=[
MyTableRow(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
MyTableRow(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
MyTableRow(id=3, name='Jack', dob=date(1992, 1, 1)),
],
columns=[
DisplayLookup(field='name', on_click=GoToEvent(url='/more/{id}/')),
DisplayLookup(field='dob', mode=DisplayMode.date),
DisplayLookup(field='enabled'),
],
c.LinkList(
links=[
c.Link(
components=[c.Text(text='Cities')],
on_click=GoToEvent(url='/table/cities'),
active='startswith:/table/cities',
),
]
c.Link(
components=[c.Text(text='Users')],
on_click=GoToEvent(url='/table/users'),
active='startswith:/table/users',
),
],
mode='tabs',
class_name='+ mb-4',
),
]

View File

@@ -14,13 +14,13 @@ class FastUI(pydantic.RootModel):
root: list[AnyComponent]
_PREBUILT_VERSION = '0.0.8'
_PREBUILT_VERSION = '0.0.9'
_PREBUILT_CDN_URL = f'https://cdn.jsdelivr.net/npm/@pydantic/fastui-prebuilt@{_PREBUILT_VERSION}/dist/assets'
def prebuilt_html(title: str = ''):
"""
Returns a very simple HTML page which includes the FastUI react frontend, loaded from https://www.jsdelivr.com/.
Returns a simple HTML page which includes the FastUI react frontend, loaded from https://www.jsdelivr.com/.
Arguments:
title: page title

View File

@@ -79,6 +79,10 @@ class FormFile:
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#unique_file_type_specifiers
for details on what's allowed
"""
if file.size == 0:
# FIXME is this right???
return
if self.max_size is not None and file.size is not None and file.size > self.max_size:
raise pydantic_core.PydanticCustomError(
'file_no_big',

View File

@@ -245,11 +245,6 @@ def special_string_field(
)
def select_options(schema: JsonSchemaStringEnum) -> list[SelectOption]:
enum_labels = schema.get('enum_labels', {})
return [SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in schema['enum']]
def loc_to_name(loc: SchemeLocation) -> str:
"""
Convert a loc to a string if any item contains a '.' or the first item starts with '[' then encode with JSON,
@@ -284,8 +279,9 @@ def deference_json_schema(
not_null_schema = next(s for s in any_of if s.get('type') != 'null')
# is there anything else apart from `default` we need to copy over?
if default := schema.get('default'):
not_null_schema['default'] = default # type: ignore
for field in 'default', 'description':
if value := schema.get(field):
not_null_schema[field] = value # type: ignore
return deference_json_schema(not_null_schema, defs, False)
else:

View File

@@ -10,14 +10,21 @@ anyio==3.7.1
# via
# fastapi
# starlette
dnspython==2.4.2
# via email-validator
email-validator==2.1.0.post1
# via pydantic
fastapi==0.104.1
# via fastui (pyproject.toml)
idna==3.4
# via anyio
# via
# anyio
# email-validator
pydantic==2.5.2
# via
# fastapi
# fastui (pyproject.toml)
# pydantic
pydantic-core==2.14.5
# via pydantic
python-multipart==0.0.6