Skip to content

Commit

Permalink
react-select working
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin committed Nov 24, 2023
1 parent 8930e2a commit ac116a2
Show file tree
Hide file tree
Showing 11 changed files with 656 additions and 53 deletions.
493 changes: 479 additions & 14 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/fastui-bootstrap/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle
case 'FormFieldInput':
case 'FormFieldCheckbox':
case 'FormFieldSelect':
case 'FormFieldSelectSearch':
case 'FormFieldFile':
return formFieldClassName(props, subElement)
case 'Navbar':
Expand All @@ -54,6 +55,8 @@ function formFieldClassName(props: components.FormFieldProps, subElement?: strin
return props.error ? 'is-invalid form-control' : 'form-control'
case 'select':
return 'form-select'
case 'select-search':
return ''
case 'label':
return { 'form-label': true, 'fw-bold': props.required }
case 'error':
Expand Down
1 change: 1 addition & 0 deletions packages/fastui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"react-select": "^5.8.0",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0"
},
Expand Down
65 changes: 61 additions & 4 deletions packages/fastui/src/components/FormField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { FC, useState } from 'react'
import AsyncSelect from 'react-select/async'
import { StylesConfig } from 'react-select'

import { ClassName, useClassName } from '../hooks/className'
import { request, debounce } from '../tools'

interface BaseFormFieldProps {
name: string
Expand All @@ -12,7 +15,12 @@ interface BaseFormFieldProps {
className?: ClassName
}

export type FormFieldProps = FormFieldInputProps | FormFieldCheckboxProps | FormFieldSelectProps | FormFieldFileProps
export type FormFieldProps =
| FormFieldInputProps
| FormFieldCheckboxProps
| FormFieldSelectProps
| FormFieldSelectSearchProps
| FormFieldFileProps

interface FormFieldInputProps extends BaseFormFieldProps {
type: 'FormFieldInput'
Expand All @@ -23,16 +31,14 @@ interface FormFieldInputProps extends BaseFormFieldProps {

export const FormFieldInputComp: FC<FormFieldInputProps> = (props) => {
const { name, placeholder, required, htmlType, locked } = props
const [value, setValue] = useState(props.initial ?? '')

return (
<div className={useClassName(props)}>
<Label {...props} />
<input
type={htmlType}
className={useClassName(props, { el: 'input' })}
value={value}
onChange={(e) => setValue(e.target.value)}
defaultValue={props.initial}
id={inputId(props)}
name={name}
required={required}
Expand Down Expand Up @@ -106,6 +112,57 @@ export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
)
}

interface SearchOption {
value: string
label: string
}

interface FormFieldSelectSearchProps extends BaseFormFieldProps {
type: 'FormFieldSelectSearch'
searchUrl: string
debounce?: number
initial?: SearchOption
}

// cheat slightly and match bootstrap 😱
const styles: StylesConfig = {
control: (base) => ({ ...base, borderRadius: '0.375rem', border: '1px solid #dee2e6' }),
}

type OptionsCallback = (options: SearchOption[]) => void

export const FormFieldSelectSearchComp: FC<FormFieldSelectSearchProps> = (props) => {
const { name, required, locked, searchUrl, initial } = props

const loadOptions = debounce((inputValue: string, callback: OptionsCallback) => {
request({
url: searchUrl,
query: { q: inputValue },
}).then(([, response]) => {
const { options } = response as { options: SearchOption[] }
callback(options)
})
}, props.debounce ?? 300)

return (
<div className={useClassName(props)}>
<Label {...props} />
<AsyncSelect
id={inputId(props)}
className={useClassName(props, { el: 'select-search' })}
loadOptions={loadOptions}
defaultValue={initial}
name={name}
required={required}
isDisabled={locked}
aria-describedby={descId(props)}
styles={styles}
/>
<ErrorDescription {...props} />
</div>
)
}

interface FormFieldFileProps extends BaseFormFieldProps {
type: 'FormFieldFile'
multiple: boolean
Expand Down
3 changes: 3 additions & 0 deletions packages/fastui/src/components/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
FormFieldInputComp,
FormFieldCheckboxComp,
FormFieldSelectComp,
FormFieldSelectSearchComp,
FormFieldFileComp,
} from './FormField'
import { ButtonComp, ButtonProps } from './button'
Expand Down Expand Up @@ -138,6 +139,8 @@ export const AnyComp: FC<FastProps> = (props) => {
return <FormFieldCheckboxComp {...props} />
case 'FormFieldSelect':
return <FormFieldSelectComp {...props} />
case 'FormFieldSelectSearch':
return <FormFieldSelectSearchComp {...props} />
case 'FormFieldFile':
return <FormFieldFileComp {...props} />
case 'Modal':
Expand Down
19 changes: 19 additions & 0 deletions packages/fastui/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ interface Request {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
// defaults to 200
expectedStatus?: number[]
query?: Record<string, string>
json?: Record<string, any>
formData?: FormData
headers?: Record<string, string>
Expand All @@ -22,6 +23,7 @@ export async function request({
url,
method,
headers,
query,
json,
expectedStatus,
formData,
Expand All @@ -39,6 +41,11 @@ export async function request({
method = method ?? 'POST'
}

if (query) {
const searchParams = new URLSearchParams(query)
url = `${url}?${searchParams.toString()}`
}

headers = headers ?? {}
if (contentType && !headers['Content-Type']) {
headers['Content-Type'] = contentType
Expand Down Expand Up @@ -99,3 +106,15 @@ function responseOk(response: Response, expectedStatus?: number[]) {
export function unreachable(msg: string, unexpectedValue: never, args?: any) {
console.warn(msg, { unexpectedValue }, args)
}

type Callable = (...args: any[]) => void

export function debounce<C extends Callable>(fn: C, delay: number): C {
let timerId: any

// @ts-expect-error - functions are contravariant, so this should be fine, no idea how to satisfy TS though
return (...args: any[]) => {
clearTimeout(timerId)
timerId = setTimeout(() => fn(...args), delay)
}
}
17 changes: 15 additions & 2 deletions python/demo/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from fastui import components as c
from fastui.display import Display
from fastui.events import BackEvent, GoToEvent, PageEvent
from fastui.forms import FormFile, FormResponse, fastui_form
from fastui.forms import FormFile, FormResponse, SelectSearchResponse, fastui_form
from pydantic import BaseModel, Field, SecretStr, field_validator
from pydantic_core import PydanticCustomError

Expand Down Expand Up @@ -94,7 +94,6 @@ def read_root() -> list[AnyComponent]:
],
open_trigger=PageEvent(name='dynamic-modal'),
),
c.Code(text='print("Hello World")', language='python'),
],
),
]
Expand Down Expand Up @@ -165,6 +164,7 @@ class MyFormModel(BaseModel):
# enabled: bool = False
# nested: NestedFormModel
password: SecretStr
search: str = Field(json_schema_extra={'search_url': '/api/search'})

@field_validator('name')
def name_validator(cls, v: str) -> str:
Expand All @@ -173,6 +173,19 @@ def name_validator(cls, v: str) -> str:
return v


@app.get('/api/search', response_model=SelectSearchResponse)
async def search_view(q: str) -> SelectSearchResponse:
print(f'Searching for {q}')
await asyncio.sleep(1)
return SelectSearchResponse(
options=[
{'value': '1', 'label': f'Option 1 - {q}'},
{'value': '2', 'label': 'Option 2'},
{'value': '3', 'label': 'Option 3'},
]
)


@app.get('/api/form', response_model=FastUI, response_model_exclude_none=True)
def form_view() -> list[AnyComponent]:
return [
Expand Down
2 changes: 0 additions & 2 deletions python/fastui/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
'Div',
'Page',
'Heading',
'Row',
'Col',
'Button',
'Modal',
'ModelForm',
Expand Down
11 changes: 10 additions & 1 deletion python/fastui/components/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pydantic

from .. import forms
from . import extra

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -41,13 +42,21 @@ class FormFieldSelect(BaseFormField):
type: typing.Literal['FormFieldSelect'] = 'FormFieldSelect'


class FormFieldSelectSearch(BaseFormField):
search_url: str = pydantic.Field(serialization_alias='searchUrl')
initial: forms.SelectSearchOption | None = None
# time in ms to debounce requests by, defaults to 300ms
debounce: int | None = None
type: typing.Literal['FormFieldSelectSearch'] = 'FormFieldSelectSearch'


class FormFieldFile(BaseFormField):
multiple: bool = False
accept: str | None = None
type: typing.Literal['FormFieldFile'] = 'FormFieldFile'


FormField = FormFieldInput | FormFieldCheckbox | FormFieldSelect | FormFieldFile
FormField = FormFieldInput | FormFieldCheckbox | FormFieldSelect | FormFieldSelectSearch | FormFieldFile


class BaseForm(pydantic.BaseModel, ABC, defer_build=True):
Expand Down
19 changes: 17 additions & 2 deletions python/fastui/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
import fastapi
import pydantic
import pydantic_core
import typing_extensions
from pydantic_core import core_schema
from starlette import datastructures as ds

from . import events, json_schema
from . import events

__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile'
if typing.TYPE_CHECKING:
from . import json_schema

__all__ = 'FastUIForm', 'fastui_form', 'FormResponse', 'FormFile', 'SelectSearchResponse', 'SelectSearchOption'

FormModel = typing.TypeVar('FormModel', bound=pydantic.BaseModel)

Expand Down Expand Up @@ -120,6 +124,8 @@ def __get_pydantic_core_schema__(self, source_type: type[typing.Any], *_args) ->
raise TypeError(f'FormFile can only be used with `UploadFile` or `list[UploadFile]`, not {source_type}')

def __get_pydantic_json_schema__(self, core_schema_: core_schema.CoreSchema, *_args) -> json_schema.JsonSchemaFile:
from . import json_schema

function = core_schema_.get('function', {}).get('function')
multiple = bool(function and function.__name__ == 'validate_multiple')
s = json_schema.JsonSchemaFile(type='string', format='binary', multiple=multiple)
Expand All @@ -136,6 +142,15 @@ class FormResponse(pydantic.BaseModel):
type: typing.Literal['FormResponse'] = 'FormResponse'


class SelectSearchOption(typing_extensions.TypedDict):
value: str
label: str


class SelectSearchResponse(pydantic.BaseModel):
options: list[SelectSearchOption]


NestedDict: typing.TypeAlias = 'dict[str | int, NestedDict | str | list[str] | ds.UploadFile | list[ds.UploadFile]]'


Expand Down
Loading

0 comments on commit ac116a2

Please sign in to comment.