Skip to content

Commit

Permalink
extend demo
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin committed Dec 1, 2023
1 parent ad37db2 commit db73fe1
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 79 deletions.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
162 changes: 93 additions & 69 deletions python/demo/forms.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,24 @@
from __future__ import annotations as _annotations

from collections import defaultdict
from datetime import date
from enum import StrEnum
from typing import Annotated, Literal
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, Field, SecretStr, field_validator
from pydantic import BaseModel, EmailStr, 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'
Expand All @@ -79,28 +42,32 @@ async def search_view(request: Request, q: str) -> SelectSearchResponse:
return SelectSearchResponse(options=options)


FormKind: TypeAlias = Literal['login', 'select', 'big']


@router.get('/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]:
def forms_view(kind: FormKind) -> 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',
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='Form Two')],
on_click=PageEvent(name='change-form', push_path='/forms/two', context={'kind': 'two'}),
active='/forms/two',
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='Form Three')],
on_click=PageEvent(name='change-form', push_path='/forms/three', context={'kind': 'three'}),
active='/forms/three',
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}',
Expand All @@ -112,40 +79,97 @@ def form_view(kind: Literal['one', 'two', 'three']) -> list[AnyComponent]:


@router.get('/content/{kind}', response_model=FastUI, response_model_exclude_none=True)
def form_content(kind: Literal['one', 'two', 'three']):
def form_content(kind: FormKind):
match kind:
case 'one':
case 'login':
return [
c.Heading(text='Form One', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
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'),
# footer=[
# c.Button(text='Cancel', on_click=GoToEvent(url='/')),
# c.Button(text='Submit', html_type='submit'),
# ]
),
]
case 'two':
case 'select':
return [
c.Heading(text='Form Two', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
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 'three':
case 'big':
return [
c.Heading(text='Form Three', level=2),
c.ModelForm[MyFormModel](
submit_url='/api/form',
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}')


@router.post('/form')
async def form_post(form: Annotated[MyFormModel, fastui_form(MyFormModel)]) -> FormResponse:
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 change: 1 addition & 0 deletions python/demo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def api_index() -> list[AnyComponent]:
* `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)
"""
return demo_page(c.Markdown(text=markdown))

Expand Down
4 changes: 3 additions & 1 deletion python/demo/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo
active='startswith:/table',
),
c.Link(
components=[c.Text(text='Forms')], on_click=GoToEvent(url='/forms/one'), active='startswith:/forms'
components=[c.Text(text='Forms')],
on_click=GoToEvent(url='/forms/login'),
active='startswith:/forms',
),
],
),
Expand Down
4 changes: 4 additions & 0 deletions python/fastui/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ def _validate_file(self, file: ds.UploadFile) -> None:
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',
Expand Down
10 changes: 3 additions & 7 deletions python/fastui/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion python/requirements/pyproject.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit db73fe1

Please sign in to comment.