diff --git a/packages/fastui-bootstrap/src/index.tsx b/packages/fastui-bootstrap/src/index.tsx index e828891e..225983b1 100644 --- a/packages/fastui-bootstrap/src/index.tsx +++ b/packages/fastui-bootstrap/src/index.tsx @@ -27,7 +27,7 @@ function displayPrimitiveRender(props: components.DisplayPrimitiveProps) { } } -export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subElement }) => { +export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subElement }): ClassName => { const { type } = props switch (type) { case 'Page': @@ -52,78 +52,72 @@ export const classNameGenerator: ClassNameGenerator = ({ props, fullPath, subEle } case 'Form': case 'ModelForm': - return formClassName(subElement) + if (props.displayMode === 'inline') { + switch (subElement) { + case 'form-container': + return '' + default: + return 'row row-cols-lg-4 align-items-center justify-content-end' + } + } else { + switch (subElement) { + case 'form-container': + return 'row justify-content-center' + default: + return 'col-md-4' + } + } case 'FormFieldInput': case 'FormFieldCheckbox': case 'FormFieldSelect': case 'FormFieldSelectSearch': case 'FormFieldFile': - return formFieldClassName(props, subElement) + switch (subElement) { + case 'input': + return props.error ? 'is-invalid form-control' : 'form-control' + case 'select': + return 'form-select' + case 'select-react': + return '' + case 'label': + if (props.displayMode === 'inline') { + return 'visually-hidden' + } else { + return { 'form-label': true, 'fw-bold': props.required } + } + case 'error': + return 'invalid-feedback' + case 'description': + return 'form-text' + default: + return 'mb-3' + } case 'Navbar': - return navbarClassName(subElement) + switch (subElement) { + case 'contents': + return 'container' + case 'title': + return 'navbar-brand' + default: + return 'navbar navbar-expand-lg bg-body-tertiary' + } case 'Link': - return linkClassName(props, fullPath) + return { + active: pathMatch(props.active, fullPath), + 'nav-link': props.mode === 'navbar' || props.mode === 'tabs', + } case 'LinkList': - return linkListClassName(props, subElement) - } -} - -function formFieldClassName(props: components.FormFieldProps, subElement?: string): ClassName { - switch (subElement) { - case 'input': - 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': - return 'invalid-feedback' - case 'description': - return 'form-text' - default: - return 'mb-3' - } -} - -function formClassName(subElement?: string): ClassName { - switch (subElement) { - case 'form-container': - return 'row justify-content-center' - default: - return 'col-md-4' - } -} - -function navbarClassName(subElement?: string): ClassName { - switch (subElement) { - case 'contents': - return 'container' - case 'title': - return 'navbar-brand' - default: - return 'navbar navbar-expand-lg bg-body-tertiary' - } -} - -function linkClassName(props: components.LinkProps, fullPath: string): ClassName { - return { - active: pathMatch(props.active, fullPath), - 'nav-link': props.mode === 'navbar' || props.mode === 'tabs', - } -} - -function linkListClassName(props: components.LinkListProps, subElement?: string): ClassName { - if (subElement === 'link-list-item' && props.mode) { - return 'nav-item' - } - switch (props.mode) { - case 'tabs': - return 'nav nav-underline' - case 'vertical': - return 'nav flex-column' - default: - return '' + if (subElement === 'link-list-item' && props.mode) { + return 'nav-item' + } else { + switch (props.mode) { + case 'tabs': + return 'nav nav-underline' + case 'vertical': + return 'nav flex-column' + default: + return '' + } + } } } diff --git a/packages/fastui/src/components/FormField.tsx b/packages/fastui/src/components/FormField.tsx index 71270958..d7c733cc 100644 --- a/packages/fastui/src/components/FormField.tsx +++ b/packages/fastui/src/components/FormField.tsx @@ -12,7 +12,9 @@ interface BaseFormFieldProps { locked: boolean error?: string description?: string + displayMode?: 'default' | 'inline' className?: ClassName + onChange?: () => void } export type FormFieldProps = @@ -128,57 +130,82 @@ interface FormFieldSelectProps extends BaseFormFieldProps { initial?: string multiple?: boolean vanilla?: boolean + placeholder?: string } export const FormFieldSelectComp: FC = (props) => { - const { name, required, locked, options, multiple, initial, vanilla } = props + if (props.vanilla) { + return + } else { + return + } +} + +export const FormFieldSelectVanillaComp: FC = (props) => { + const { name, required, locked, options, multiple, initial, placeholder, onChange } = props const className = useClassName(props) const classNameSelect = useClassName(props, { el: 'select' }) + return ( +
+
+ ) +} + +export const FormFieldSelectReactComp: FC = (props) => { + const { name, required, locked, options, multiple, initial, placeholder, onChange } = props + + const className = useClassName(props) const classNameSelectReact = useClassName(props, { el: 'select-react' }) - if (vanilla) { - return ( -
-
- ) - } else { - return ( -
-
+ ) } const SelectOptionComp: FC<{ option: SelectOption | SelectGroup }> = ({ option }) => { @@ -214,10 +241,11 @@ interface FormFieldSelectSearchProps extends BaseFormFieldProps { debounce?: number initial?: SelectOption multiple?: boolean + placeholder?: string } export const FormFieldSelectSearchComp: FC = (props) => { - const { name, required, locked, searchUrl, initial, multiple } = props + const { name, required, locked, searchUrl, initial, multiple, placeholder, onChange } = props const [isLoading, setIsLoading] = useState(false) const request = useRequest() @@ -237,10 +265,18 @@ export const FormFieldSelectSearchComp: FC = (props) }) }, props.debounce ?? 300) + const reactSelectOnChanged = () => { + // TODO this is a hack to wait for the input to be updated, can we do better? + setTimeout(() => { + onChange && onChange() + }, 50) + } + return (
diff --git a/packages/fastui/src/components/form.tsx b/packages/fastui/src/components/form.tsx index f268b31f..e7bbd7bb 100644 --- a/packages/fastui/src/components/form.tsx +++ b/packages/fastui/src/components/form.tsx @@ -1,8 +1,9 @@ -import { FC, FormEvent, useState } from 'react' +import { FC, FormEvent, useContext, useState, useRef, useCallback } from 'react' import { ClassName, useClassName } from '../hooks/className' import { useFireEvent, AnyEvent } from '../events' -import { useRequest } from '../tools' +import { useRequest, RequestArgs } from '../tools' +import { LocationContext } from '../hooks/locationContext' import { FastProps, AnyCompList } from './index' @@ -11,8 +12,12 @@ import { FormFieldProps } from './FormField' interface BaseFormProps { formFields: FormFieldProps[] + initial?: Record submitUrl: string footer?: boolean | FastProps[] + method?: 'GET' | 'GOTO' | 'POST' + displayMode?: 'default' | 'inline' + submitOnChange?: boolean className?: ClassName } @@ -30,7 +35,8 @@ interface FormResponse { } export const FormComp: FC = (props) => { - const { formFields, submitUrl, footer } = props + const formRef = useRef(null) + const { formFields, initial, submitUrl, method, footer, displayMode, submitOnChange } = props // mostly equivalent to ` = (props) => { const [error, setError] = useState(null) const { fireEvent } = useFireEvent() const request = useRequest() + const { goto } = useContext(LocationContext) + + const submit = useCallback( + async (formData: FormData) => { + setLocked(true) + setError(null) + setFieldErrors({}) + + if (method === 'GOTO') { + // this seems to work in common cases, but typescript doesn't like it + const query = new URLSearchParams(formData as any) + console.log(Object.fromEntries(query.entries())) + for (const [k, v] of query.entries()) { + console.log(k, v) + if (v === '') { + query.delete(k) + } + } + const queryStr = query.toString() + goto(queryStr === '' ? submitUrl : `${submitUrl}?${query}`) + setLocked(false) + return + } - const onSubmit = async (e: FormEvent) => { - e.preventDefault() - setLocked(true) - setError(null) - setFieldErrors({}) - const formData = new FormData(e.currentTarget) - - const [status, data] = await request({ url: submitUrl, formData, expectedStatus: [200, 422] }) - if (status === 200) { - if (data.type !== 'FormResponse') { - throw new Error(`Expected FormResponse, got ${JSON.stringify(data)}`) + const requestArgs: RequestArgs = { url: submitUrl, expectedStatus: [200, 422] } + if (method === 'GET') { + // as above with URLSearchParams + requestArgs.query = new URLSearchParams(formData as any) + } else { + requestArgs.formData = formData } - const { event } = data as FormResponse - fireEvent(event) - } else { - // status === 422 - const errorResponse = data as ErrorResponse - const formErrors = errorResponse.detail.form - if (formErrors) { - setFieldErrors(Object.fromEntries(formErrors.map((e) => [locToName(e.loc), e.msg]))) + + const [status, data] = await request(requestArgs) + if (status === 200) { + if (data.type !== 'FormResponse') { + throw new Error(`Expected FormResponse, got ${JSON.stringify(data)}`) + } + const { event } = data as FormResponse + fireEvent(event) } else { - console.warn('Non-field error submitting form:', data) - setError('Error submitting form') + // status === 422 + const errorResponse = data as ErrorResponse + const formErrors = errorResponse.detail.form + if (formErrors) { + setFieldErrors(Object.fromEntries(formErrors.map((e) => [locToName(e.loc), e.msg]))) + } else { + console.warn('Non-field error submitting form:', data) + setError('Error submitting form') + } } - } - setLocked(false) + setLocked(false) + }, + [goto, method, request, submitUrl, fireEvent], + ) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + const formData = new FormData(e.currentTarget) + await submit(formData) } - const fieldProps: FormFieldProps[] = formFields.map((formField) => - Object.assign({}, formField, { error: fieldErrors[formField.name], locked }), - ) + const onChange = useCallback(() => { + if (submitOnChange) { + const formData = new FormData(formRef.current!) + submit(formData) + } + }, [submitOnChange, submit]) + + const fieldProps: FormFieldProps[] = formFields.map((formField) => { + const f = { + ...formField, + error: fieldErrors[formField.name], + locked, + displayMode, + onChange, + } as FormFieldProps + const formInitial = initial && initial[formField.name] + if (formInitial !== undefined) { + ;(f as any).initial = formInitial + } + return f + }) return (
-
+ {error ?
Error: {error}
: null}