Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Radio buttons #177

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions demo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class ToolEnum(str, enum.Enum):

class SelectForm(BaseModel):
select_single: ToolEnum = Field(title='Select Single')
select_radio: ToolEnum = Field(title='Select Radio', json_schema_extra={'mode': 'radio'})
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'})
Expand Down
1 change: 1 addition & 0 deletions src/npm-fastui-bootstrap/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const classNameGenerator: ClassNameGenerator = ({
case 'FormFieldBoolean':
case 'FormFieldSelect':
case 'FormFieldSelectSearch':
case 'FormFieldRadio':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at your screenshot vs the bootstrap docs for Radios it appears you're missing some styling.

I think you need ot add some more classes here for radios, perhaps form-check, form-check-input and form-check-label?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added both form-check-input and form-check-label - but i am unsure where i should add the class form-check, i tried a few different places but it never seemed to work as intended.
as it stands, here is how it looks:
image

case 'FormFieldFile':
switch (subElement) {
case 'textarea':
Expand Down
58 changes: 58 additions & 0 deletions src/npm-fastui/src/components/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
SelectOption,
SelectOptions,
SelectGroup,
FormFieldRadio,
} from '../models'

import { useClassName } from '../hooks/className'
Expand Down Expand Up @@ -304,6 +305,62 @@ export const FormFieldSelectSearchComp: FC<FormFieldSelectSearchProps> = (props)
)
}

interface FormFieldRadioProps extends FormFieldRadio {
onChange?: PrivateOnChange
}

export const FormFieldRadioComp: FC<FormFieldRadioProps> = (props) => {
const { name, required, locked, options, initial } = props
const className = useClassName(props)
const inputClassName = useClassName(props, { el: 'radio-option' })

return (
<div className={className}>
<Label {...props} />
{options.map((option, i) => {
if ('options' in option) {
// option is a SelectGroup
return option.options.map((subOption, j) => (
tim-habitat marked this conversation as resolved.
Show resolved Hide resolved
<div key={`${i}-${j}`}>
<input
type="radio"
id={`${inputId(props)}-${i}-${j}`}
className={inputClassName}
name={name}
value={subOption.value}
defaultChecked={subOption.value === initial}
required={required}
disabled={locked}
aria-describedby={descId(props)}
/>
<label htmlFor={`${inputId(props)}-${i}-${j}`}>{subOption.label}</label>
</div>
))
} else {
// option is a SelectOption
return (
<div key={i}>
<input
type="radio"
id={`${inputId(props)}-${i}`}
className={inputClassName}
name={name}
value={option.value}
defaultChecked={option.value === initial}
required={required}
disabled={locked}
aria-describedby={descId(props)}
/>
<label htmlFor={`${inputId(props)}-${i}`}>{option.label}</label>
</div>
)
}
})}
<ErrorDescription {...props} />
</div>
)
}

const Label: FC<FormFieldProps> = (props) => {
let { title } = props
if (!Array.isArray(title)) {
Expand All @@ -327,6 +384,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)
Expand Down
3 changes: 3 additions & 0 deletions src/npm-fastui/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
FormFieldSelectComp,
FormFieldSelectSearchComp,
FormFieldFileComp,
FormFieldRadioComp,
} from './FormField'
import { ButtonComp } from './button'
import { LinkComp, LinkRender } from './link'
Expand Down Expand Up @@ -136,6 +137,8 @@ export const AnyComp: FC<FastProps> = (props) => {
return <FormFieldSelectComp {...props} />
case 'FormFieldSelectSearch':
return <FormFieldSelectSearchComp {...props} />
case 'FormFieldRadio':
return <FormFieldRadioComp {...props} />
case 'Modal':
return <ModalComp {...props} />
case 'Table':
Expand Down
16 changes: 16 additions & 0 deletions src/npm-fastui/src/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type FastProps =
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
| FormFieldRadio
| ModelForm
export type ClassName =
| string
Expand Down Expand Up @@ -324,6 +325,7 @@ export interface Form {
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
| FormFieldRadio
)[]
type: 'Form'
}
Expand Down Expand Up @@ -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?: {
Expand All @@ -441,5 +456,6 @@ export interface ModelForm {
| FormFieldFile
| FormFieldSelect
| FormFieldSelectSearch
| FormFieldRadio
)[]
}
2 changes: 2 additions & 0 deletions src/python-fastui/fastui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
FormFieldBoolean,
FormFieldFile,
FormFieldInput,
FormFieldRadio,
FormFieldSelect,
FormFieldSelectSearch,
ModelForm,
Expand Down Expand Up @@ -65,6 +66,7 @@
'FormFieldInput',
'FormFieldSelect',
'FormFieldSelectSearch',
'FormFieldRadio',
)


Expand Down
14 changes: 13 additions & 1 deletion src/python-fastui/fastui/components/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]


Expand Down
31 changes: 21 additions & 10 deletions src/python-fastui/fastui/json_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
FormFieldBoolean,
FormFieldFile,
FormFieldInput,
FormFieldRadio,
FormFieldSelect,
FormFieldSelectSearch,
FormFieldTextarea,
Expand Down Expand Up @@ -259,16 +260,26 @@ 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'),
)
if schema.get('mode') == 'radio' and not multiple:
tim-habitat marked this conversation as resolved.
Show resolved Hide resolved
return FormFieldRadio(
name=name,
title=title,
required=required,
options=[SelectOption(value=v, label=enum_labels.get(v) or as_title(v)) for v in enum],
tim-habitat marked this conversation as resolved.
Show resolved Hide resolved
initial=schema.get('default'),
description=schema.get('description'),
)
else:
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'),
)
elif search_url := schema.get('search_url'):
return FormFieldSelectSearch(
search_url=search_url,
Expand Down
72 changes: 71 additions & 1 deletion src/python-fastui/tests/test_forms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from contextlib import asynccontextmanager
from enum import Enum
from io import BytesIO
from typing import List, Tuple, Union

import pytest
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

Expand Down Expand Up @@ -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'},
],
}
],
}