Skip to content

Commit

Permalink
Add react-select (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin authored Nov 25, 2023
1 parent 8930e2a commit 41bf68a
Show file tree
Hide file tree
Showing 13 changed files with 852 additions and 112 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-react':
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
201 changes: 165 additions & 36 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 Select, { StylesConfig } from 'react-select'

import { ClassName, useClassName } from '../hooks/className'
import { debounce, useRequest } 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
| FormFieldFileProps
| FormFieldSelectProps
| FormFieldSelectSearchProps

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 @@ -71,62 +77,185 @@ export const FormFieldCheckboxComp: FC<FormFieldCheckboxProps> = (props) => {
)
}

interface FormFieldSelectProps extends BaseFormFieldProps {
type: 'FormFieldSelect'
choices: [string, string][]
initial?: string
interface FormFieldFileProps extends BaseFormFieldProps {
type: 'FormFieldFile'
multiple?: boolean
accept?: string
}

export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
const { name, required, locked, choices } = props
const [value, setValue] = useState(props.initial ?? '')
export const FormFieldFileComp: FC<FormFieldFileProps> = (props) => {
const { name, required, locked, multiple, accept } = props

return (
<div className={useClassName(props)}>
<Label {...props} />
<select
<input
type="file"
className={useClassName(props, { el: 'input' })}
id={inputId(props)}
className={useClassName(props, { el: 'select' })}
value={value}
onChange={(e) => setValue(e.target.value)}
name={name}
required={required}
disabled={locked}
aria-describedby={descId(props)}
>
<option></option>
{choices.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
multiple={multiple ?? false}
accept={accept}
/>
<ErrorDescription {...props} />
</div>
)
}

interface FormFieldFileProps extends BaseFormFieldProps {
type: 'FormFieldFile'
multiple: boolean
accept?: string
interface SelectOption {
value: string
label: string
}

export const FormFieldFileComp: FC<FormFieldFileProps> = (props) => {
const { name, required, locked, multiple, accept } = props
interface SelectGroup {
label: string
options: SelectOption[]
}

type SelectOptions = SelectOption[] | SelectGroup[]

// cheat slightly and match bootstrap 😱
// TODO make this configurable as an argument to `FastUI`
const styles: StylesConfig = {
control: (base) => ({ ...base, borderRadius: '0.375rem', border: '1px solid #dee2e6' }),
}

interface FormFieldSelectProps extends BaseFormFieldProps {
type: 'FormFieldSelect'
options: SelectOptions
initial?: string
multiple?: boolean
vanilla?: boolean
}

export const FormFieldSelectComp: FC<FormFieldSelectProps> = (props) => {
const { name, required, locked, options, multiple, initial, vanilla } = props

const className = useClassName(props)
const classNameSelect = useClassName(props, { el: 'select' })
const classNameSelectReact = useClassName(props, { el: 'select-react' })
if (vanilla) {
return (
<div className={className}>
<Label {...props} />
<select
id={inputId(props)}
className={classNameSelect}
defaultValue={initial}
multiple={multiple}
name={name}
required={required}
disabled={locked}
aria-describedby={descId(props)}
>
{multiple ? null : <option></option>}
{options.map((option, i) => (
<SelectOptionComp key={i} option={option} />
))}
</select>
<ErrorDescription {...props} />
</div>
)
} else {
return (
<div className={className}>
<Label {...props} />
<Select
id={inputId(props)}
className={classNameSelectReact}
isMulti={multiple ?? false}
isClearable
defaultValue={findDefault(options, initial)}
name={name}
required={required}
isDisabled={locked}
options={options}
aria-describedby={descId(props)}
styles={styles}
/>
<ErrorDescription {...props} />
</div>
)
}
}

const SelectOptionComp: FC<{ option: SelectOption | SelectGroup }> = ({ option }) => {
if ('options' in option) {
return (
<optgroup label={option.label}>
{option.options.map((o) => (
<SelectOptionComp key={o.value} option={o} />
))}
</optgroup>
)
} else {
return <option value={option.value}>{option.label}</option>
}
}

function findDefault(options: SelectOptions, value?: string): SelectOption | undefined {
for (const option of options) {
if ('options' in option) {
const found = findDefault(option.options, value)
if (found) {
return found
}
} else if (option.value === value) {
return option
}
}
}

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

export const FormFieldSelectSearchComp: FC<FormFieldSelectSearchProps> = (props) => {
const { name, required, locked, searchUrl, initial, multiple } = props
const [isLoading, setIsLoading] = useState(false)
const request = useRequest()

const loadOptions = debounce((inputValue: string, callback: (options: SelectOptions) => void) => {
setIsLoading(true)
request({
url: searchUrl,
query: { q: inputValue },
})
.then(([, response]) => {
const { options } = response as { options: SelectOptions }
callback(options)
setIsLoading(false)
})
.catch(() => {
setIsLoading(false)
})
}, props.debounce ?? 300)

return (
<div className={useClassName(props)}>
<Label {...props} />
<input
type="file"
className={useClassName(props, { el: 'input' })}
<AsyncSelect
id={inputId(props)}
className={useClassName(props, { el: 'select-react' })}
isMulti={multiple ?? false}
cacheOptions
isClearable
defaultOptions
loadOptions={loadOptions}
defaultValue={initial}
noOptionsMessage={({ inputValue }) => (inputValue ? 'No results' : 'Type to search')}
name={name}
required={required}
disabled={locked}
multiple={multiple}
accept={accept}
isDisabled={locked}
isLoading={isLoading}
aria-describedby={descId(props)}
styles={styles}
/>
<ErrorDescription {...props} />
</div>
Expand Down
13 changes: 5 additions & 8 deletions packages/fastui/src/components/ServerLoad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FC, useContext, useEffect, useState } from 'react'

import { ErrorContext } from '../hooks/error'
import { ReloadContext } from '../hooks/dev'
import { request } from '../tools'
import { useRequest } from '../tools'
import { DefaultLoading } from '../DefaultLoading'
import { ConfigContext } from '../hooks/config'

Expand All @@ -19,9 +19,9 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {
const { error, setError } = useContext(ErrorContext)
const reloadValue = useContext(ReloadContext)
const { rootUrl, pathSendMode, Loading } = useContext(ConfigContext)
const request = useRequest()

useEffect(() => {
// setViewData(null)
let fetchUrl = rootUrl
if (pathSendMode === 'query') {
fetchUrl += `?path=${encodeURIComponent(url)}`
Expand All @@ -31,15 +31,12 @@ export const ServerLoadComp: FC<ServerLoadProps> = ({ url }) => {

const promise = request({ url: fetchUrl })

promise
.then(([, data]) => setComponentProps(data as FastProps[]))
.catch((e) => {
setError({ title: 'Request Error', description: e.message })
})
promise.then(([, data]) => setComponentProps(data as FastProps[]))

return () => {
promise.then(() => null)
}
}, [rootUrl, pathSendMode, url, setError, reloadValue])
}, [rootUrl, pathSendMode, url, setError, reloadValue, request])

if (componentProps === null) {
if (error) {
Expand Down
3 changes: 2 additions & 1 deletion packages/fastui/src/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FC, FormEvent, useState } from 'react'

import { ClassName, useClassName } from '../hooks/className'
import { useFireEvent, AnyEvent } from '../hooks/events'
import { request } from '../tools'
import { useRequest } from '../tools'

import { FastProps, AnyCompList } from './index'

Expand Down Expand Up @@ -37,6 +37,7 @@ export const FormComp: FC<FormProps | ModelFormProps> = (props) => {
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)
const { fireEvent } = useFireEvent()
const request = useRequest()

const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
Expand Down
7 changes: 5 additions & 2 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 @@ -136,10 +137,12 @@ export const AnyComp: FC<FastProps> = (props) => {
return <FormFieldInputComp {...props} />
case 'FormFieldCheckbox':
return <FormFieldCheckboxComp {...props} />
case 'FormFieldSelect':
return <FormFieldSelectComp {...props} />
case 'FormFieldFile':
return <FormFieldFileComp {...props} />
case 'FormFieldSelect':
return <FormFieldSelectComp {...props} />
case 'FormFieldSelectSearch':
return <FormFieldSelectSearchComp {...props} />
case 'Modal':
return <ModalComp {...props} />
case 'Table':
Expand Down
Loading

0 comments on commit 41bf68a

Please sign in to comment.