mirror of
https://github.com/samuelcolvin/FastUI.git
synced 2023-12-01 22:22:11 +03:00
Better demo (#17)
This commit is contained in:
@@ -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
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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$/, ''),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
188
python/demo/components_list.py
Normal file
188
python/demo/components_list.py
Normal 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
175
python/demo/forms.py
Normal 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='/'))
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user