diff --git a/demo/forms.py b/demo/forms.py index f0f7c7ef..07fb678f 100644 --- a/demo/forms.py +++ b/demo/forms.py @@ -124,6 +124,7 @@ class ToolEnum(str, enum.Enum): class SelectForm(BaseModel): select_single: ToolEnum = Field(title='Select Single') select_multiple: list[ToolEnum] = Field(title='Select Multiple') + select_radio: ToolEnum = Field(title='Select Radio', json_schema_extra={'mode': 'radio'}) 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'}) diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx index b2fef51f..2f8b9d4c 100644 --- a/src/npm-fastui-bootstrap/src/index.tsx +++ b/src/npm-fastui-bootstrap/src/index.tsx @@ -75,6 +75,7 @@ export const classNameGenerator: ClassNameGenerator = ({ case 'FormFieldBoolean': case 'FormFieldSelect': case 'FormFieldSelectSearch': + case 'FormFieldRadio': case 'FormFieldFile': switch (subElement) { case 'textarea': @@ -98,6 +99,10 @@ export const classNameGenerator: ClassNameGenerator = ({ return 'invalid-feedback' case 'description': return 'form-text' + case 'radio-input': + return 'form-check-input' + case 'radio-label': + return 'form-check-label' default: return { 'mb-3': true, diff --git a/src/npm-fastui/src/components/FormField.tsx b/src/npm-fastui/src/components/FormField.tsx index 0e67959b..8d3dffd3 100644 --- a/src/npm-fastui/src/components/FormField.tsx +++ b/src/npm-fastui/src/components/FormField.tsx @@ -12,6 +12,7 @@ import type { SelectOption, SelectOptions, SelectGroup, + FormFieldRadio, } from '../models' import { useClassName } from '../hooks/className' @@ -304,6 +305,53 @@ export const FormFieldSelectSearchComp: FC = (props) ) } +interface FormFieldRadioProps extends FormFieldRadio { + onChange?: PrivateOnChange +} + +export const FormFieldRadioComp: FC = (props) => { + const { name, required, locked, options, initial } = props + const className = useClassName(props) + const inputClassName = useClassName(props, { el: 'radio-input' }) + const labelClassName = useClassName(props, { el: 'radio-label' }) + + const renderRadioInput = (option: SelectOption, i: number, j: number | null = null) => { + const index = j !== null ? `${i}-${j}` : `${i}` + return ( +
+ + +
+ ) + } + + return ( +
+
+ ) +} const Label: FC = (props) => { let { title } = props if (!Array.isArray(title)) { @@ -327,6 +375,7 @@ export type FormFieldProps = | FormFieldFileProps | FormFieldSelectProps | FormFieldSelectSearchProps + | FormFieldRadio const inputId = (props: FormFieldProps) => `form-field-${props.name}` const descId = (props: FormFieldProps) => (props.description ? `${inputId(props)}-desc` : undefined) diff --git a/src/npm-fastui/src/components/index.tsx b/src/npm-fastui/src/components/index.tsx index 1cd84748..87362056 100644 --- a/src/npm-fastui/src/components/index.tsx +++ b/src/npm-fastui/src/components/index.tsx @@ -21,6 +21,7 @@ import { FormFieldSelectComp, FormFieldSelectSearchComp, FormFieldFileComp, + FormFieldRadioComp, } from './FormField' import { ButtonComp } from './button' import { LinkComp, LinkRender } from './link' @@ -136,6 +137,8 @@ export const AnyComp: FC = (props) => { return case 'FormFieldSelectSearch': return + case 'FormFieldRadio': + return case 'Modal': return case 'Table': diff --git a/src/npm-fastui/src/models.d.ts b/src/npm-fastui/src/models.d.ts index 461118fe..ddabbd90 100644 --- a/src/npm-fastui/src/models.d.ts +++ b/src/npm-fastui/src/models.d.ts @@ -38,6 +38,7 @@ export type FastProps = | FormFieldFile | FormFieldSelect | FormFieldSelectSearch + | FormFieldRadio | ModelForm export type ClassName = | string @@ -324,6 +325,7 @@ export interface Form { | FormFieldFile | FormFieldSelect | FormFieldSelectSearch + | FormFieldRadio )[] type: 'Form' } @@ -422,6 +424,19 @@ export interface FormFieldSelectSearch { placeholder?: string type: 'FormFieldSelectSearch' } +export interface FormFieldRadio { + name: string + title: string[] | string + required?: boolean + error?: string + locked?: boolean + description?: string + displayMode?: 'default' | 'inline' + className?: ClassName + options: SelectOptions + initial?: string + type: 'FormFieldRadio' +} export interface ModelForm { submitUrl: string initial?: { @@ -441,5 +456,6 @@ export interface ModelForm { | FormFieldFile | FormFieldSelect | FormFieldSelectSearch + | FormFieldRadio )[] } diff --git a/src/python-fastui/fastui/components/__init__.py b/src/python-fastui/fastui/components/__init__.py index 2969a05f..dff43178 100644 --- a/src/python-fastui/fastui/components/__init__.py +++ b/src/python-fastui/fastui/components/__init__.py @@ -21,6 +21,7 @@ FormFieldBoolean, FormFieldFile, FormFieldInput, + FormFieldRadio, FormFieldSelect, FormFieldSelectSearch, ModelForm, @@ -65,6 +66,7 @@ 'FormFieldInput', 'FormFieldSelect', 'FormFieldSelectSearch', + 'FormFieldRadio', ) diff --git a/src/python-fastui/fastui/components/forms.py b/src/python-fastui/fastui/components/forms.py index 539fb6b5..d6a0d370 100644 --- a/src/python-fastui/fastui/components/forms.py +++ b/src/python-fastui/fastui/components/forms.py @@ -73,8 +73,20 @@ class FormFieldSelectSearch(BaseFormField): type: _t.Literal['FormFieldSelectSearch'] = 'FormFieldSelectSearch' +class FormFieldRadio(BaseFormField): + options: forms.SelectOptions + initial: _t.Union[str, None] = None + type: _t.Literal['FormFieldRadio'] = 'FormFieldRadio' + + FormField = _t.Union[ - FormFieldInput, FormFieldTextarea, FormFieldBoolean, FormFieldFile, FormFieldSelect, FormFieldSelectSearch + FormFieldInput, + FormFieldTextarea, + FormFieldBoolean, + FormFieldFile, + FormFieldSelect, + FormFieldSelectSearch, + FormFieldRadio, ] diff --git a/src/python-fastui/fastui/json_schema.py b/src/python-fastui/fastui/json_schema.py index eb209e6f..b9fe6200 100644 --- a/src/python-fastui/fastui/json_schema.py +++ b/src/python-fastui/fastui/json_schema.py @@ -10,6 +10,7 @@ FormFieldBoolean, FormFieldFile, FormFieldInput, + FormFieldRadio, FormFieldSelect, FormFieldSelectSearch, FormFieldTextarea, @@ -259,16 +260,29 @@ def special_string_field( ) elif enum := schema.get('enum'): enum_labels = schema.get('enum_labels', {}) - return FormFieldSelect( - name=name, - title=title, - placeholder=schema.get('placeholder'), - required=required, - multiple=multiple, - options=[SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum], - initial=schema.get('default'), - description=schema.get('description'), - ) + options = [SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum] + if schema.get('mode') == 'radio' and multiple: + raise ValueError('Radio buttons are not supported for multiple choice fields') + elif schema.get('mode') == 'radio' and not multiple: + return FormFieldRadio( + name=name, + title=title, + required=required, + options=options, + initial=schema.get('default'), + description=schema.get('description'), + ) + else: + return FormFieldSelect( + name=name, + title=title, + placeholder=schema.get('placeholder'), + required=required, + multiple=multiple, + options=options, + initial=schema.get('default'), + description=schema.get('description'), + ) elif search_url := schema.get('search_url'): return FormFieldSelectSearch( search_url=search_url, diff --git a/src/python-fastui/tests/test_forms.py b/src/python-fastui/tests/test_forms.py index b0919fad..9451647e 100644 --- a/src/python-fastui/tests/test_forms.py +++ b/src/python-fastui/tests/test_forms.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from enum import Enum from io import BytesIO from typing import List, Tuple, Union @@ -6,7 +7,7 @@ from fastapi import HTTPException from fastui import components from fastui.forms import FormFile, Textarea, fastui_form -from pydantic import BaseModel +from pydantic import BaseModel, Field from starlette.datastructures import FormData, Headers, UploadFile from typing_extensions import Annotated @@ -469,3 +470,72 @@ def test_form_textarea_form_fields(): } ], } + + +class Choices(Enum): + foo = 'foo' + bar = 'bar' + baz = 'baz' + + +class FormRadioSelection(BaseModel): + choice: Choices = Field(..., json_schema_extra={'mode': 'radio'}) + + +def test_form_radio_form_fields(): + m = components.ModelForm(model=FormRadioSelection, submit_url='/foobar/') + + assert m.model_dump(by_alias=True, exclude_none=True) == { + 'submitUrl': '/foobar/', + 'method': 'POST', + 'type': 'ModelForm', + 'formFields': [ + { + 'name': 'choice', + 'title': ['Choices'], + 'required': True, + 'locked': False, + 'type': 'FormFieldRadio', + 'options': [ + {'label': 'Foo', 'value': 'foo'}, + {'label': 'Bar', 'value': 'bar'}, + {'label': 'Baz', 'value': 'baz'}, + ], + } + ], + } + + +@pytest.mark.parametrize('multiple', [True, False]) +def test_form_from_select(multiple: bool): + if multiple: + + class FormSelect(BaseModel): + choices: List[Choices] + else: + + class FormSelect(BaseModel): + choice: Choices + + m = components.ModelForm(model=FormSelect, submit_url='/foobar/') + + assert m.model_dump(by_alias=True, exclude_none=True) == { + 'submitUrl': '/foobar/', + 'method': 'POST', + 'type': 'ModelForm', + 'formFields': [ + { + 'name': 'choices' if multiple else 'choice', + 'multiple': multiple, + 'title': ['Choices'], + 'required': True, + 'locked': False, + 'type': 'FormFieldSelect', + 'options': [ + {'label': 'Foo', 'value': 'foo'}, + {'label': 'Bar', 'value': 'bar'}, + {'label': 'Baz', 'value': 'baz'}, + ], + } + ], + }