Skip to content

Commit

Permalink
Merge branch 'main' into issue-185
Browse files Browse the repository at this point in the history
  • Loading branch information
sydney-runkle authored May 30, 2024
2 parents 8a37a71 + fa88ab2 commit 0a319d3
Show file tree
Hide file tree
Showing 8 changed files with 132 additions and 10 deletions.
9 changes: 9 additions & 0 deletions demo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ class SelectForm(BaseModel):
search_select_single: str = Field(json_schema_extra={'search_url': '/api/forms/search'})
search_select_multiple: list[str] = Field(json_schema_extra={'search_url': '/api/forms/search'})

@field_validator('select_multiple', 'search_select_multiple', mode='before')
@classmethod
def correct_select_multiple(cls, v: list[str]) -> list[str]:
if isinstance(v, list):
return v
else:
return [v]


@router.post('/select', response_model=FastUI, response_model_exclude_none=True)
async def select_form_post(form: Annotated[SelectForm, fastui_form(SelectForm)]):
Expand All @@ -144,6 +152,7 @@ class BigModel(BaseModel):
None, description='This field is not required, it must start with a capital letter if provided'
)
info: Annotated[str | None, Textarea(rows=5)] = Field(None, description='Optional free text information about you.')
repo: str = Field(json_schema_extra={'placeholder': '{org}/{repo}'}, title='GitHub repository')
profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] = Field(
description='Upload a profile picture, must not be more than 16kb'
)
Expand Down
6 changes: 4 additions & 2 deletions demo/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,12 @@ class User(BaseModel):
name: str = Field(title='Name')
dob: date = Field(title='Date of Birth')
enabled: bool | None = None
status_markdown: str | None = Field(default=None, title='Status')


users: list[User] = [
User(id=1, name='John', dob=date(1990, 1, 1), enabled=True),
User(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False),
User(id=1, name='John', dob=date(1990, 1, 1), enabled=True, status_markdown='**Active**'),
User(id=2, name='Jane', dob=date(1991, 1, 1), enabled=False, status_markdown='*Inactive*'),
User(id=3, name='Jack', dob=date(1992, 1, 1)),
]

Expand All @@ -115,6 +116,7 @@ def users_view() -> list[AnyComponent]:
DisplayLookup(field='name', on_click=GoToEvent(url='/table/users/{id}/')),
DisplayLookup(field='dob', mode=DisplayMode.date),
DisplayLookup(field='enabled'),
DisplayLookup(field='status_markdown', mode=DisplayMode.markdown),
],
),
title='Users',
Expand Down
17 changes: 17 additions & 0 deletions demo/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.testclient import TestClient

from . import app
from .forms import ToolEnum


@pytest.fixture
Expand Down Expand Up @@ -74,4 +75,20 @@ def test_menu_links(client: TestClient, url: str):
assert isinstance(data, list)


def test_forms_validate_correct_select_multiple():
with client as _client:
countries = _client.get('api/forms/search', params={'q': None})
countries_options = countries.json()['options']
r = client.post(
'api/forms/select',
data={
'select_single': ToolEnum._member_names_[0],
'select_multiple': ToolEnum._member_names_[0],
'search_select_single': countries_options[0]['options'][0]['value'],
'search_select_multiple': countries_options[0]['options'][0]['value'],
},
)
assert r.status_code == 200


# TODO tests for forms, including submission
36 changes: 32 additions & 4 deletions src/npm-fastui/src/components/display.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { FC } from 'react'

import type { AnyEvent, DisplayMode, Display, JsonData } from '../models'
import type { AnyEvent, DisplayMode, Display, JsonData, FastProps } from '../models'

import { useCustomRender } from '../hooks/config'
import { unreachable, asTitle } from '../tools'

import { AnyComp } from '.'

import { JsonComp } from './Json'
import { LinkRender } from './link'
import MarkdownComp from './MarkdownLazy'

export const DisplayComp: FC<Display> = (props) => {
const CustomRenderComp = useCustomRender(props)
Expand All @@ -26,6 +29,28 @@ export const DisplayComp: FC<Display> = (props) => {
}
}

// todo: this list should probably be defined in the models file
const nestableSubcomponents = [
'Text',
'Paragraph',
'Div',
'Heading',
'Markdown',
'Code',
'Json',
'Button',
'Link',
'LinkList',
'ServerLoad',
'Image',
'Iframe',
'Video',
'Spinner',
'Custom',
'Table',
'Details',
]

const DisplayRender: FC<Display> = (props) => {
const mode = props.mode ?? 'auto'
const value = props.value ?? null
Expand All @@ -34,7 +59,11 @@ const DisplayRender: FC<Display> = (props) => {
} else if (Array.isArray(value)) {
return <DisplayArray mode={mode} value={value} />
} else if (typeof value === 'object' && value !== null) {
return <DisplayObject mode={mode} value={value} />
if (value.type !== null && typeof value.type === 'string' && nestableSubcomponents.includes(value.type)) {
return <AnyComp {...(value as unknown as FastProps)} />
} else {
return <DisplayObject mode={mode} value={value} />
}
} else {
return <DisplayPrimitive mode={mode} value={value} />
}
Expand Down Expand Up @@ -178,8 +207,7 @@ const DisplayMarkdown: FC<{ value: JSONPrimitive }> = ({ value }) => {
if (value === null) {
return <DisplayNull />
} else {
// TODO
return <>{value.toString()}</>
return <MarkdownComp text={value.toString()} type="Markdown" />
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/python-fastui/fastui/components/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ class Details(BaseModel, extra='forbid'):

@pydantic.model_validator(mode='after')
def _fill_fields(self) -> _te.Self:
fields = {**self.data.model_fields, **self.data.model_computed_fields}

if self.fields is None:
self.fields = [
DisplayLookup(field=name, title=field.title) for name, field in self.data.model_fields.items()
]
self.fields = [DisplayLookup(field=name, title=field.title) for name, field in fields.items()]
else:
# add pydantic titles to fields that don't have them
for field in (c for c in self.fields if c.title is None):
Expand Down
1 change: 1 addition & 0 deletions src/python-fastui/fastui/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def json_schema_field_to_field(
initial=schema.get('default'),
autocomplete=schema.get('autocomplete'),
description=schema.get('description'),
placeholder=schema.get('placeholder'),
)


Expand Down
25 changes: 25 additions & 0 deletions src/python-fastui/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,28 @@ def test_form_description_leakage():
'submitUrl': '/foobar/',
'type': 'ModelForm',
}


class RichForm(BaseModel):
repo: str = Field(json_schema_extra={'placeholder': '{org}/{repo}'}, title='GitHub repository')


def test_form_fields():
m = components.ModelForm(model=RichForm, submit_url='/foobar/')

assert m.model_dump(by_alias=True, exclude_none=True) == {
'formFields': [
{
'htmlType': 'text',
'locked': False,
'name': 'repo',
'placeholder': '{org}/{repo}',
'required': True,
'title': ['GitHub repository'],
'type': 'FormFieldInput',
}
],
'method': 'POST',
'submitUrl': '/foobar/',
'type': 'ModelForm',
}
42 changes: 41 additions & 1 deletion src/python-fastui/tests/test_tables_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ def test_display_no_fields():
# insert_assert(d.model_dump(by_alias=True, exclude_none=True))
assert d.model_dump(by_alias=True, exclude_none=True) == {
'data': {'id': 1, 'name': 'john', 'representation': '1: john'},
'fields': [{'field': 'id'}, {'title': 'Name', 'field': 'name'}],
'fields': [
{'field': 'id'},
{'title': 'Name', 'field': 'name'},
{'title': 'Representation', 'field': 'representation'},
],
'type': 'Details',
}

Expand Down Expand Up @@ -122,5 +126,41 @@ def test_details_with_display_lookup_and_display():
{'title': 'Name', 'field': 'name'},
{'title': 'Display Title', 'value': 'display value', 'type': 'Display'},
],


def test_table_respect_computed_field_title():
class Foo(BaseModel):
id: int

@computed_field(title='Foo Name')
def name(self) -> str:
return f'foo{self.id}'

foos = [Foo(id=1)]
table = components.Table(data=foos)

# insert_assert(table.model_dump(by_alias=True, exclude_none=True))
assert table.model_dump(by_alias=True, exclude_none=True) == {
'data': [{'id': 1, 'name': 'foo1'}],
'columns': [{'field': 'id'}, {'title': 'Foo Name', 'field': 'name'}],
'type': 'Table',
}


def test_details_respect_computed_field_title():
class Foo(BaseModel):
id: int

@computed_field(title='Foo Name')
def name(self) -> str:
return f'foo{self.id}'

foos = Foo(id=1)
details = components.Details(data=foos)

# insert_assert(table.model_dump(by_alias=True, exclude_none=True))
assert details.model_dump(by_alias=True, exclude_none=True) == {
'data': {'id': 1, 'name': 'foo1'},
'fields': [{'field': 'id'}, {'title': 'Foo Name', 'field': 'name'}],
'type': 'Details',
}

0 comments on commit 0a319d3

Please sign in to comment.