diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx index 1ef39abf..e828891e 100644 --- a/packages/fastui-bootstrap/src/index.tsx +++ b/packages/fastui-bootstrap/src/index.tsx @@ -35,7 +35,21 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle case 'Button': return 'btn btn-primary' case 'Table': - return 'table table-striped table-bordered' + switch (subElement) { + case 'no-data-message': + return 'text-center mt-2' + default: + return 'table table-striped table-bordered' + } + case 'Details': + switch (subElement) { + case 'dt': + return 'col-sm-3 col-md-2 text-sm-end' + case 'dd': + return 'col-sm-9 col-md-10' + default: + return 'row' + } case 'Form': case 'ModelForm': return formClassName(subElement) diff --git a/packages/fastui/src/components/details.tsx b/packages/fastui/src/components/details.tsx new file mode 100644 index 00000000..119994da --- /dev/null +++ b/packages/fastui/src/components/details.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react' + +import { asTitle } from '../tools' +import { ClassName, useClassName } from '../hooks/className' + +import { DisplayComp, DisplayLookupProps, ModelData, renderEvent } from './display' + +export interface DetailsProps { + type: 'Details' + data: ModelData + fields: DisplayLookupProps[] + className?: ClassName +} + +export const DetailsComp: FC = (props) => ( +
+ {props.fields.map((field, id) => ( + + ))} +
+) + +const FieldDetail: FC<{ props: DetailsProps; fieldDisplay: DisplayLookupProps }> = ({ props, fieldDisplay }) => { + const { field, title, onClick, ...rest } = fieldDisplay + const value = props.data[field] + const renderedOnClick = renderEvent(onClick, props.data) + return ( + <> +
{title ?? asTitle(field)}
+
+ +
+ + ) +} diff --git a/packages/fastui/src/components/display.tsx b/packages/fastui/src/components/display.tsx index c34185e6..e292fa0c 100644 --- a/packages/fastui/src/components/display.tsx +++ b/packages/fastui/src/components/display.tsx @@ -1,15 +1,30 @@ import { FC } from 'react' import { useCustomRender } from '../hooks/config' -import { DisplayChoices, asTitle } from '../display' -import { unreachable } from '../tools' +import { unreachable, asTitle } from '../tools' +import { AnyEvent } from '../events' import { JsonComp, JsonData } from './Json' +import { LinkRender } from './link' -interface DisplayProps { +export enum DisplayMode { + auto = 'auto', + plain = 'plain', + datetime = 'datetime', + date = 'date', + duration = 'duration', + as_title = 'as_title', + markdown = 'markdown', + json = 'json', + inline_code = 'inline_code', +} + +export interface DisplayProps { type: 'Display' - display?: DisplayChoices value?: JsonData + mode?: DisplayMode + title?: string + onClick?: AnyEvent } export const DisplayComp: FC = (props) => { @@ -18,22 +33,35 @@ export const DisplayComp: FC = (props) => { return } - const display = props.display ?? DisplayChoices.auto + const { onClick } = props + if (onClick) { + return ( + + + + ) + } else { + return + } +} + +const DisplayRender: FC = (props) => { + const mode = props.mode ?? DisplayMode.auto const value = props.value ?? null - if (display === DisplayChoices.json) { + if (mode === DisplayMode.json) { return } else if (Array.isArray(value)) { - return + return } else if (typeof value === 'object' && value !== null) { - return + return } else { - return + return } } interface DisplayArrayProps { value: JsonData[] - display?: DisplayChoices + mode?: DisplayMode type: 'DisplayArray' } @@ -42,13 +70,13 @@ export const DisplayArray: FC = (props) => { if (CustomRenderComp) { return } - const { display, value } = props + const { mode, value } = props return ( <> {value.map((v, i) => ( - ,{' '} + ,{' '} ))} @@ -57,7 +85,7 @@ export const DisplayArray: FC = (props) => { interface DisplayObjectProps { value: { [key: string]: JsonData } - display?: DisplayChoices + mode?: DisplayMode type: 'DisplayObject' } @@ -66,13 +94,13 @@ export const DisplayObject: FC = (props) => { if (CustomRenderComp) { return } - const { display, value } = props + const { mode, value } = props return ( <> {Object.entries(value).map(([key, v], i) => ( - {key}: ,{' '} + {key}: ,{' '} ))} @@ -83,7 +111,7 @@ type JSONPrimitive = string | number | boolean | null export interface DisplayPrimitiveProps { value: JSONPrimitive - display: DisplayChoices + mode: DisplayMode type: 'DisplayPrimitive' } @@ -92,28 +120,28 @@ export const DisplayPrimitive: FC = (props) => { if (CustomRenderComp) { return } - const { display, value } = props + const { mode, value } = props - switch (display) { - case DisplayChoices.auto: + switch (mode) { + case DisplayMode.auto: return - case DisplayChoices.plain: + case DisplayMode.plain: return - case DisplayChoices.datetime: + case DisplayMode.datetime: return - case DisplayChoices.date: + case DisplayMode.date: return - case DisplayChoices.duration: + case DisplayMode.duration: return - case DisplayChoices.as_title: + case DisplayMode.as_title: return - case DisplayChoices.markdown: + case DisplayMode.markdown: return - case DisplayChoices.json: - case DisplayChoices.inline_code: + case DisplayMode.json: + case DisplayMode.inline_code: return default: - unreachable('Unexpected display type', display, props) + unreachable('Unexpected display type', mode, props) } } @@ -198,3 +226,46 @@ const DisplayInlineCode: FC<{ value: JSONPrimitive }> = ({ value }) => { return {value.toString()} } } + +export type ModelData = Record + +export interface DisplayLookupProps extends Omit { + field: string + tableWidthPercent?: number +} + +export function renderEvent(event: AnyEvent | undefined, data: ModelData): AnyEvent | undefined { + let newEvent: AnyEvent | undefined = event ? { ...event } : undefined + if (newEvent) { + if (newEvent.type === 'go-to' && newEvent.url) { + // for go-to events with a URL, substitute the row values into the url + const url = subKeys(newEvent.url, data) + if (url === null) { + newEvent = undefined + } else { + newEvent.url = url + } + } + } + return newEvent +} + +const subKeys = (template: string, row: ModelData): string | null => { + let returnNull = false + const r = template.replace(/{(.+?)}/g, (_, key: string): string => { + const v: JsonData | undefined = row[key] + if (v === undefined) { + throw new Error(`field "${key}" not found in ${JSON.stringify(row)}`) + } else if (v === null) { + returnNull = true + return 'null' + } else { + return v.toString() + } + }) + if (returnNull) { + return null + } else { + return r + } +} diff --git a/packages/fastui/src/components/index.tsx b/packages/fastui/src/components/index.tsx index 9f1b5047..ee58e6d0 100644 --- a/packages/fastui/src/components/index.tsx +++ b/packages/fastui/src/components/index.tsx @@ -27,6 +27,7 @@ import { NavbarProps, NavbarComp } from './navbar' import { ModalComp, ModalProps } from './modal' import { TableComp, TableProps } from './table' import { PaginationProps, PaginationComp } from './pagination' +import { DetailsProps, DetailsComp } from './details' import { AllDisplayProps, DisplayArray, @@ -54,6 +55,7 @@ export type { ModalProps, TableProps, PaginationProps, + DetailsProps, LinkProps, LinkListProps, NavbarProps, @@ -82,6 +84,7 @@ export type FastProps = | ModalProps | TableProps | PaginationProps + | DetailsProps | LinkProps | LinkListProps | NavbarProps @@ -152,6 +155,8 @@ export const AnyComp: FC = (props) => { return case 'Pagination': return + case 'Details': + return case 'Display': return case 'DisplayArray': diff --git a/packages/fastui/src/components/table.tsx b/packages/fastui/src/components/table.tsx index e356168f..281fc523 100644 --- a/packages/fastui/src/components/table.tsx +++ b/packages/fastui/src/components/table.tsx @@ -1,41 +1,28 @@ import { FC, CSSProperties } from 'react' -import type { JsonData } from './Json' - -import { DisplayChoices, asTitle } from '../display' +import { asTitle } from '../tools' import { ClassName, useClassName } from '../hooks/className' -import { AnyEvent } from '../events' - -import { DisplayComp } from './display' -import { LinkRender } from './link' - -interface ColumnProps { - field: string - display?: DisplayChoices - title?: string - onClick?: AnyEvent - widthPercent?: number - className?: ClassName -} -type Row = Record +import { DisplayComp, DisplayLookupProps, ModelData, renderEvent } from './display' export interface TableProps { type: 'Table' - data: Row[] - columns: ColumnProps[] + data: ModelData[] + columns: DisplayLookupProps[] + noDataMessage?: string className?: ClassName } export const TableComp: FC = (props) => { - const { columns, data } = props + const { columns, data, noDataMessage } = props + const noDataClassName = useClassName(props, { el: 'no-data-message' }) return ( {columns.map((col, id) => ( - ))} @@ -45,73 +32,25 @@ export const TableComp: FC = (props) => { {data.map((row, rowId) => ( {columns.map((column, id) => ( - + ))} ))} + {data.length === 0 && }
+ {col.title ?? asTitle(col.field)}
{noDataMessage || 'No data'}
) } const colWidth = (w: number | undefined): CSSProperties | undefined => (w ? { width: `${w}%` } : undefined) -interface CellProps { - row: Row - column: ColumnProps - rowId: number -} - -const Cell: FC = ({ row, column }) => { - const { field, display, onClick } = column +const Cell: FC<{ row: ModelData; column: DisplayLookupProps }> = ({ row, column }) => { + const { field, onClick, ...rest } = column const value = row[field] - let event: AnyEvent | null = onClick ? { ...onClick } : null - if (event) { - if (event.type === 'go-to') { - // for go-to events, substitute the row values into the url - if (event.url) { - const url = subKeys(event.url, row) - if (url === null) { - event = null - } else { - event.url = url - } - } - } - } - if (event) { - return ( - - - - - - ) - } else { - return ( - - - - ) - } -} - -const subKeys = (template: string, row: Row): string | null => { - let returnNull = false - const r = template.replace(/{(.+?)}/g, (_, key: string): string => { - const v: JsonData | undefined = row[key] - if (v === undefined) { - throw new Error(`field "${key}" not found in ${JSON.stringify(row)}`) - } else if (v === null) { - returnNull = true - return 'null' - } else { - return v.toString() - } - }) - if (returnNull) { - return null - } else { - return r - } + const renderedOnClick = renderEvent(onClick, row) + return ( + + + + ) } diff --git a/packages/fastui/src/display.ts b/packages/fastui/src/display.ts deleted file mode 100644 index 07759211..00000000 --- a/packages/fastui/src/display.ts +++ /dev/null @@ -1,14 +0,0 @@ -export enum DisplayChoices { - auto = 'auto', - plain = 'plain', - datetime = 'datetime', - date = 'date', - duration = 'duration', - as_title = 'as_title', - markdown = 'markdown', - json = 'json', - inline_code = 'inline_code', -} - -// usage as_title('what_ever') > 'What Ever' -export const asTitle = (s: string): string => s.replace(/[_-]/g, ' ').replace(/(_|\b)\w/g, (l) => l.toUpperCase()) diff --git a/packages/fastui/src/index.tsx b/packages/fastui/src/index.tsx index 23218fa1..67da415f 100644 --- a/packages/fastui/src/index.tsx +++ b/packages/fastui/src/index.tsx @@ -11,7 +11,7 @@ import { FastProps } from './components' import { DevReload } from './dev' export * as components from './components' export * as events from './events' -export type { DisplayChoices } from './display' +export type { DisplayMode } from './components/display' export type { ClassName, ClassNameGenerator } from './hooks/className' export { useClassName, renderClassName } from './hooks/className' export { pathMatch } from './hooks/locationContext' diff --git a/packages/fastui/src/tools.ts b/packages/fastui/src/tools.ts index 95180d56..a06464fa 100644 --- a/packages/fastui/src/tools.ts +++ b/packages/fastui/src/tools.ts @@ -160,3 +160,6 @@ export function debounce(fn: C, delay: number): C { export async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } + +// usage `as_title('what_ever') > 'What Ever'` +export const asTitle = (s: string): string => s.replace(/[_-]/g, ' ').replace(/(_|\b)\w/g, (l) => l.toUpperCase()) diff --git a/python/demo/main.py b/python/demo/main.py index 722dc334..55962e00 100644 --- a/python/demo/main.py +++ b/python/demo/main.py @@ -39,7 +39,7 @@ def read_root() -> list[AnyComponent]: 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). +This is some **Markdown**, link to [table](/table/cities). ```python x = 1 diff --git a/python/demo/shared.py b/python/demo/shared.py index e84f57ef..715cbb44 100644 --- a/python/demo/shared.py +++ b/python/demo/shared.py @@ -10,7 +10,9 @@ def navbar() -> AnyComponent: title='FastUI Demo', links=[ c.Link(components=[c.Text(text='Home')], on_click=GoToEvent(url='/'), active='/'), - c.Link(components=[c.Text(text='Table')], on_click=GoToEvent(url='/table'), active='/table'), + 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'), ], ) diff --git a/python/demo/tables.py b/python/demo/tables.py index 3f5886b3..85f1f0bf 100644 --- a/python/demo/tables.py +++ b/python/demo/tables.py @@ -3,9 +3,10 @@ from pathlib import Path from fastapi import APIRouter -from fastui import AnyComponent, Display, FastUI +from fastui import AnyComponent, FastUI from fastui import components as c -from fastui.events import GoToEvent +from fastui.components.display import DisplayLookup, DisplayMode +from fastui.events import BackEvent, GoToEvent from pydantic import BaseModel, Field, TypeAdapter from .shared import navbar @@ -13,23 +14,27 @@ router = APIRouter() -def tabs() -> AnyComponent: - return c.LinkList( - links=[ - c.Link( - components=[c.Text(text='Cities')], - on_click=GoToEvent(url='/table'), - active='/table', - ), - c.Link( - components=[c.Text(text='Users')], - on_click=GoToEvent(url='/table/users'), - active='/table/users', - ), - ], - mode='tabs', - class_name='+ mb-4', - ) +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): @@ -47,7 +52,7 @@ class City(BaseModel): @cache -def get_cities() -> list[City]: +def cities_list() -> list[City]: cities_adapter = TypeAdapter(list[City]) cities_file = Path(__file__).parent / 'cities.json' cities = cities_adapter.validate_json(cities_file.read_bytes()) @@ -55,24 +60,27 @@ def get_cities() -> list[City]: return cities -@router.get('', response_model=FastUI, response_model_exclude_none=True) +@cache +def cities_lookup() -> dict[id, City]: + return {city.id: city for city in cities_list()} + + +@router.get('/cities', response_model=FastUI, response_model_exclude_none=True) def cities_view(page: int = 1) -> list[AnyComponent]: - cities = get_cities() + cities = cities_list() page_size = 50 return [ navbar(), c.PageTitle(text='FastUI Demo - Table'), c.Page( components=[ - c.Heading(text='Tables'), - tabs(), - c.Heading(text='Cities', level=3), + *heading('Cities'), c.Table[City]( data=cities[(page - 1) * page_size : page * page_size], columns=[ - c.TableColumn(field='city', on_click=GoToEvent(url='/more/{id}/'), width_percent=33), - c.TableColumn(field='country', width_percent=33), - c.TableColumn(field='population', width_percent=33), + 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)), @@ -81,6 +89,22 @@ def cities_view(page: int = 1) -> list[AnyComponent]: ] +@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), + ] + ), + ] + + class MyTableRow(BaseModel): id: int = Field(title='ID') name: str = Field(title='Name') @@ -95,9 +119,7 @@ def users_view() -> list[AnyComponent]: c.PageTitle(text='FastUI Demo - Table'), c.Page( components=[ - c.Heading(text='Table'), - tabs(), - c.Heading(text='Users', level=3), + *heading('Users'), c.Table[MyTableRow]( data=[ MyTableRow(id=1, name='John', dob=date(1990, 1, 1), enabled=True), @@ -105,9 +127,9 @@ def users_view() -> list[AnyComponent]: MyTableRow(id=3, name='Jack', dob=date(1992, 1, 1)), ], columns=[ - c.TableColumn(field='name', on_click=GoToEvent(url='/more/{id}/')), - c.TableColumn(field='dob', display=Display.date), - c.TableColumn(field='enabled'), + DisplayLookup(field='name', on_click=GoToEvent(url='/more/{id}/')), + DisplayLookup(field='dob', mode=DisplayMode.date), + DisplayLookup(field='enabled'), ], ), ] diff --git a/python/fastui/__init__.py b/python/fastui/__init__.py index 34d541ac..5a53bf7c 100644 --- a/python/fastui/__init__.py +++ b/python/fastui/__init__.py @@ -4,9 +4,8 @@ from .components import AnyComponent from .dev import dev_fastapi_app -from .display import Display -__all__ = 'AnyComponent', 'FastUI', 'dev_fastapi_app', 'Display' +__all__ = 'AnyComponent', 'FastUI', 'dev_fastapi_app' class FastUI(pydantic.RootModel): diff --git a/python/fastui/components/extra.py b/python/fastui/class_name.py similarity index 72% rename from python/fastui/components/extra.py rename to python/fastui/class_name.py index 2aa743a1..fbcaabf4 100644 --- a/python/fastui/components/extra.py +++ b/python/fastui/class_name.py @@ -1,3 +1,4 @@ +# could be renamed to something general if there's more to add from typing import Annotated from pydantic import Field diff --git a/python/fastui/components/__init__.py b/python/fastui/components/__init__.py index f295c7ac..eeea6435 100644 --- a/python/fastui/components/__init__.py +++ b/python/fastui/components/__init__.py @@ -11,10 +11,11 @@ import pydantic +from .. import class_name as _class_name from .. import events -from . import extra +from .display import Details, Display from .forms import Form, FormField, ModelForm -from .tables import Pagination, Table, TableColumn +from .tables import Pagination, Table if typing.TYPE_CHECKING: import pydantic.fields @@ -30,7 +31,8 @@ 'ModelForm', 'Form', 'Table', - 'TableColumn', + 'Display', + 'Details', ) @@ -55,7 +57,7 @@ class PageTitle(pydantic.BaseModel, extra='forbid'): class Div(pydantic.BaseModel, extra='forbid'): components: list[AnyComponent] - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Div'] = 'Div' @@ -65,7 +67,7 @@ class Page(pydantic.BaseModel, extra='forbid'): """ components: list[AnyComponent] - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Page'] = 'Page' @@ -73,7 +75,7 @@ class Heading(pydantic.BaseModel, extra='forbid'): text: str level: typing.Literal[1, 2, 3, 4, 5, 6] = 1 html_id: str | None = pydantic.Field(default=None, serialization_alias='htmlId') - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Heading'] = 'Heading' @@ -85,7 +87,7 @@ class Heading(pydantic.BaseModel, extra='forbid'): class Markdown(pydantic.BaseModel, extra='forbid'): text: str code_style: CodeStyle = None - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Markdown'] = 'Markdown' @@ -93,7 +95,7 @@ class Code(pydantic.BaseModel, extra='forbid'): text: str language: str | None = None code_style: CodeStyle = None - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Code'] = 'Code' @@ -103,7 +105,7 @@ class Button(pydantic.BaseModel, extra='forbid'): html_type: typing.Literal['button', 'submit', 'reset'] | None = pydantic.Field( default=None, serialization_alias='htmlType' ) - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Button'] = 'Button' @@ -113,14 +115,14 @@ class Link(pydantic.BaseModel, extra='forbid'): mode: typing.Literal['navbar', 'tabs', 'vertical', 'pagination'] | None = None active: bool | str | None = None locked: bool | None = None - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Link'] = 'Link' class LinkList(pydantic.BaseModel, extra='forbid'): links: list[Link] mode: typing.Literal['tabs', 'vertical', 'pagination'] | None = None - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['LinkList'] = 'LinkList' @@ -128,7 +130,7 @@ class Navbar(pydantic.BaseModel, extra='forbid'): title: str | None = None title_event: events.AnyEvent | None = pydantic.Field(default=None, serialization_alias='titleEvent') links: list[Link] = pydantic.Field(default_factory=list) - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Navbar'] = 'Navbar' @@ -138,7 +140,7 @@ class Modal(pydantic.BaseModel, extra='forbid'): footer: list[AnyComponent] | None = None open_trigger: events.PageEvent | None = pydantic.Field(default=None, serialization_alias='openTrigger') open_context: events.EventContext | None = pydantic.Field(default=None, serialization_alias='openContext') - class_name: extra.ClassName = None + class_name: _class_name.ClassName = None type: typing.Literal['Modal'] = 'Modal' @@ -171,6 +173,8 @@ class ServerLoad(pydantic.BaseModel, extra='forbid'): | ServerLoad | Table | Pagination + | Display + | Details | Form | ModelForm | FormField, diff --git a/python/fastui/components/display.py b/python/fastui/components/display.py new file mode 100644 index 00000000..137a1ede --- /dev/null +++ b/python/fastui/components/display.py @@ -0,0 +1,78 @@ +import enum +import typing +from abc import ABC + +import annotated_types +import pydantic + +from .. import class_name as _class_name +from .. import events + +__all__ = 'DisplayMode', 'DisplayLookup', 'Display', 'Details' + + +class DisplayMode(enum.StrEnum): + """ + How to a value. + """ + + auto = 'auto' # default, same as None below + plain = 'plain' + datetime = 'datetime' + date = 'date' + duration = 'duration' + as_title = 'as_title' + markdown = 'markdown' + json = 'json' + inline_code = 'inline_code' + + +class DisplayBase(pydantic.BaseModel, ABC, defer_build=True): + mode: DisplayMode | None = None + on_click: events.AnyEvent | None = pydantic.Field(default=None, serialization_alias='onClick') + + +class DisplayLookup(DisplayBase, extra='forbid'): + """ + Description of how to display a value looked up from data, either in a table or detail view. + """ + + field: str + title: str | None = None + # percentage width - 0 to 100, specific to tables + table_width_percent: typing.Annotated[int, annotated_types.Interval(ge=0, le=100)] | None = pydantic.Field( + default=None, serialization_alias='tableWidthPercent' + ) + + +class Display(DisplayBase, extra='forbid'): + """ + Description of how to display a value, either in a table or detail view. + """ + + value: typing.Any + type: typing.Literal['Display'] = 'Display' + + +DataModel = typing.TypeVar('DataModel', bound=pydantic.BaseModel) + + +class Details(pydantic.BaseModel, typing.Generic[DataModel], extra='forbid'): + data: DataModel + fields: list[DisplayLookup] | None = None + class_name: _class_name.ClassName | None = None + type: typing.Literal['Details'] = 'Details' + + @pydantic.model_validator(mode='after') + def fill_fields(self) -> typing.Self: + if self.fields is None: + self.fields = [ + DisplayLookup(field=name, title=field.title) for name, field in self.data.model_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): + pydantic_field = self.data.model_fields.get(field.field) + if pydantic_field and pydantic_field.title: + field.title = pydantic_field.title + return self diff --git a/python/fastui/components/forms.py b/python/fastui/components/forms.py index df2affd9..3f35b056 100644 --- a/python/fastui/components/forms.py +++ b/python/fastui/components/forms.py @@ -5,8 +5,8 @@ import pydantic +from .. import class_name as _class_name from .. import forms -from . import extra if typing.TYPE_CHECKING: from . import AnyComponent @@ -21,7 +21,7 @@ class BaseFormField(pydantic.BaseModel, ABC, defer_build=True): error: str | None = None locked: bool = False description: str | None = None - class_name: extra.ClassName | None = None + class_name: _class_name.ClassName | None = None class FormFieldInput(BaseFormField): @@ -65,7 +65,7 @@ class FormFieldSelectSearch(BaseFormField): class BaseForm(pydantic.BaseModel, ABC, defer_build=True): submit_url: str = pydantic.Field(serialization_alias='submitUrl') footer: bool | list[AnyComponent] | None = None - class_name: extra.ClassName | None = None + class_name: _class_name.ClassName | None = None class Form(BaseForm): diff --git a/python/fastui/components/tables.py b/python/fastui/components/tables.py index 4f2cf8ef..8a011ede 100644 --- a/python/fastui/components/tables.py +++ b/python/fastui/components/tables.py @@ -2,54 +2,39 @@ import typing -import annotated_types import pydantic -from .. import events -from ..display import Display -from . import extra +from .. import class_name as _class_name +from . import display -# TODO allow dataclasses and dicts here too +# TODO allow dataclasses and typed dicts here too DataModel = typing.TypeVar('DataModel', bound=pydantic.BaseModel) -Percentage: typing.TypeAlias = typing.Annotated[int, annotated_types.Interval(ge=0, le=100)] - - -class TableColumn(pydantic.BaseModel, extra='forbid'): - """ - Description of a table column. - """ - - field: str - display: Display | None = None - title: str | None = None - on_click: typing.Annotated[events.AnyEvent | None, pydantic.Field(serialization_alias='onClick')] = None - # percentage width - 0 to 100 - width_percent: Percentage | None = pydantic.Field(default=None, serialization_alias='widthPercent') - class_name: extra.ClassName | None = None class Table(pydantic.BaseModel, typing.Generic[DataModel], extra='forbid'): data: list[DataModel] - columns: list[TableColumn] | None = None - # TODO pagination - class_name: extra.ClassName | None = None + columns: list[display.DisplayLookup] | None = None + no_data_message: str | None = pydantic.Field(default=None, serialization_alias='noDataMessage') + class_name: _class_name.ClassName | None = None type: typing.Literal['Table'] = 'Table' @pydantic.model_validator(mode='after') def fill_columns(self) -> typing.Self: + args = self.__pydantic_generic_metadata__['args'] try: - data_model_0 = self.data[0] + data_model_type: type[DataModel] = args[0] except IndexError: - return self + raise ValueError('`Table` must be parameterized with a pydantic model, i.e. `Table[MyModel]()`.') if self.columns is None: self.columns = [ - TableColumn(field=name, title=field.title) for name, field in data_model_0.model_fields.items() + display.DisplayLookup(field=name, title=field.title) + for name, field in data_model_type.model_fields.items() ] else: # add pydantic titles to columns that don't have them for column in (c for c in self.columns if c.title is None): - field = data_model_0.model_fields.get(column.field) + field = data_model_type.model_fields.get(column.field) if field and field.title: column.title = field.title return self @@ -59,7 +44,7 @@ class Pagination(pydantic.BaseModel): page: int page_size: int total: int - class_name: extra.ClassName | None = None + class_name: _class_name.ClassName | None = None type: typing.Literal['Pagination'] = 'Pagination' @pydantic.computed_field(alias='pageCount') diff --git a/python/fastui/display.py b/python/fastui/display.py deleted file mode 100644 index ff42fdf5..00000000 --- a/python/fastui/display.py +++ /dev/null @@ -1,17 +0,0 @@ -from enum import StrEnum - - -class Display(StrEnum): - """ - How to a value. - """ - - auto = 'auto' # default, same as None below - plain = 'plain' - datetime = 'datetime' - date = 'date' - duration = 'duration' - as_title = 'as_title' - markdown = 'markdown' - json = 'json' - inline_code = 'inline_code'