`
- `Markdown` - renders markdown, [example](https://fastui-demo.onrender.com)
- `Code` - renders code with highlighting in a ``
- `Button` - renders a ``
diff --git a/package-lock.json b/package-lock.json
index 704e14fc..448e8923 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/fastui-bootstrap/package.json b/packages/fastui-bootstrap/package.json
index deaf99f1..26d3cc04 100644
--- a/packages/fastui-bootstrap/package.json
+++ b/packages/fastui-bootstrap/package.json
@@ -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"
}
}
diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx
index 03d1975f..9d454e7f 100644
--- a/packages/fastui-bootstrap/src/index.tsx
+++ b/packages/fastui-bootstrap/src/index.tsx
@@ -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 () =>
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) {
diff --git a/packages/fastui-prebuilt/package.json b/packages/fastui-prebuilt/package.json
index 2f35e9b3..9e3efdf6 100644
--- a/packages/fastui-prebuilt/package.json
+++ b/packages/fastui-prebuilt/package.json
@@ -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",
diff --git a/packages/fastui-prebuilt/src/main.scss b/packages/fastui-prebuilt/src/main.scss
index d1d68e2a..7c305459 100644
--- a/packages/fastui-prebuilt/src/main.scss
+++ b/packages/fastui-prebuilt/src/main.scss
@@ -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/
diff --git a/packages/fastui/package.json b/packages/fastui/package.json
index 23bc35d9..2712cc05 100644
--- a/packages/fastui/package.json
+++ b/packages/fastui/package.json
@@ -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",
diff --git a/packages/fastui/src/components/MarkdownLazy.tsx b/packages/fastui/src/components/MarkdownLazy.tsx
index 8ef0a823..f82e5db7 100644
--- a/packages/fastui/src/components/MarkdownLazy.tsx
+++ b/packages/fastui/src/components/MarkdownLazy.tsx
@@ -26,7 +26,11 @@ const MarkdownComp: FC = (props) => {
}
return (
-
+
{text}
)
@@ -75,7 +79,24 @@ interface MarkdownCodeProps {
const MarkdownCode: FC = ({ children, className, codeStyle }) => {
const match = /language-(\w+)/.exec(className || '')
- const language = match ? match[1] : undefined
+ if (match) {
+ return (
+
+ {children}
+
+ )
+ } else {
+ return {children}
+ }
+}
+
+interface MarkdownCodeHighlightProps {
+ children: ReactNode
+ language?: string
+ codeStyle?: string
+}
+
+const MarkdownCodeHighlight: FC = ({ children, codeStyle, language }) => {
const codeProps: CodeProps = {
type: 'Code',
text: String(children).replace(/\n$/, ''),
diff --git a/packages/fastui/src/components/ServerLoad.tsx b/packages/fastui/src/components/ServerLoad.tsx
index 1d53186f..00891963 100644
--- a/packages/fastui/src/components/ServerLoad.tsx
+++ b/packages/fastui/src/components/ServerLoad.tsx
@@ -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)
+ }
+}
diff --git a/packages/fastui/src/components/heading.tsx b/packages/fastui/src/components/heading.tsx
index 800db60e..387a23b9 100644
--- a/packages/fastui/src/components/heading.tsx
+++ b/packages/fastui/src/components/heading.tsx
@@ -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 = (props) => {
const { level, text, htmlId } = props
const HeadingComponent = getComponent(level)
- return
+ return
}
function getComponent(level: 1 | 2 | 3 | 4 | 5 | 6): FC<{ text: string; id?: string; className?: string }> {
diff --git a/packages/fastui/src/hooks/locationContext.tsx b/packages/fastui/src/hooks/locationContext.tsx
index a6a83a21..0766f70a 100644
--- a/packages/fastui/src/hooks/locationContext.tsx
+++ b/packages/fastui/src/hooks/locationContext.tsx
@@ -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],
),
diff --git a/packages/fastui/src/tools.ts b/packages/fastui/src/tools.ts
index a3d1363d..7f40f6cf 100644
--- a/packages/fastui/src/tools.ts
+++ b/packages/fastui/src/tools.ts
@@ -168,3 +168,12 @@ export async function sleep(ms: number): Promise {
// 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
diff --git a/python/demo/__init__.py b/python/demo/__init__.py
index 461d0b35..4ae8b2fd 100644
--- a/python/demo/__init__.py
+++ b/python/demo/__init__.py
@@ -9,6 +9,8 @@
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 @@ async def lifespan(app_: FastAPI):
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')
diff --git a/python/demo/components_list.py b/python/demo/components_list.py
new file mode 100644
index 00000000..80b2fe53
--- /dev/null
+++ b/python/demo/components_list.py
@@ -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')
diff --git a/python/demo/forms.py b/python/demo/forms.py
new file mode 100644
index 00000000..13423d91
--- /dev/null
+++ b/python/demo/forms.py
@@ -0,0 +1,151 @@
+from __future__ import annotations as _annotations
+
+from collections import defaultdict
+from enum import StrEnum
+from typing import Annotated, Literal
+
+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, Field, SecretStr, field_validator
+from pydantic_core import PydanticCustomError
+
+from .shared import demo_page
+
+router = APIRouter()
+
+
+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/forms/search'})
+ searches: list[str] = Field(json_schema_extra={'search_url': '/api/forms/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('/{kind}', response_model=FastUI, response_model_exclude_none=True)
+def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]:
+ return demo_page(
+ c.LinkList(
+ links=[
+ c.Link(
+ components=[c.Text(text='Form One')],
+ on_click=PageEvent(name='change-form', push_path='/forms/one', context={'kind': 'one'}),
+ active='/forms/one',
+ ),
+ c.Link(
+ components=[c.Text(text='Form Two')],
+ on_click=PageEvent(name='change-form', push_path='/forms/two', context={'kind': 'two'}),
+ active='/forms/two',
+ ),
+ c.Link(
+ components=[c.Text(text='Form Three')],
+ on_click=PageEvent(name='change-form', push_path='/forms/three', context={'kind': 'three'}),
+ active='/forms/three',
+ ),
+ ],
+ mode='tabs',
+ ),
+ 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: 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='/'))
diff --git a/python/demo/main.py b/python/demo/main.py
index e2ab107b..1134e2f1 100644
--- a/python/demo/main.py
+++ b/python/demo/main.py
@@ -1,253 +1,38 @@
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).
-
-```python
-x = 1
-y = 2
-assert x + y == 3
-```
-
+ # language=markdown
+ markdown = """\
+This site providers a demo of [FastUI](https://github.com/samuelcolvin/FastUI).
+
+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)
"""
- ),
- 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)
diff --git a/python/demo/shared.py b/python/demo/shared.py
index 715cbb44..0280d308 100644
--- a/python/demo/shared.py
+++ b/python/demo/shared.py
@@ -5,14 +5,32 @@
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/one'), active='startswith:/forms'
+ ),
+ ],
+ ),
+ c.Page(
+ components=[
+ *((c.Heading(text=title),) if title else ()),
+ *components,
+ ],
+ ),
+ ]
diff --git a/python/demo/tables.py b/python/demo/tables.py
index 85dc768a..0c6d699f 100644
--- a/python/demo/tables.py
+++ b/python/demo/tables.py
@@ -10,34 +10,11 @@
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',
),
]
diff --git a/python/fastui/__init__.py b/python/fastui/__init__.py
index 1ce804eb..7742ccda 100644
--- a/python/fastui/__init__.py
+++ b/python/fastui/__init__.py
@@ -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