-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3e41c75
commit d349624
Showing
27 changed files
with
3,903 additions
and
187 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import React, { memo, useContext, useCallback } from 'react'; | ||
import { useAtom } from 'jotai'; | ||
import { Button } from '../ui/button'; | ||
import { ApiFormContext } from './ApiForm'; | ||
import { useValidation } from './hooks/useValidation'; | ||
import { get } from 'lodash'; | ||
import { toast } from '../ui/use-toast'; | ||
|
||
export interface ApiButtonProps { | ||
id: string; | ||
label: string; | ||
onClick: () => Promise<void>; | ||
validation?: { | ||
fields: string[]; | ||
validator?: (values: Record<string, { id: string; value: string; required: boolean }>) => string | undefined; | ||
}; | ||
} | ||
|
||
export const ApiButton = memo(({ | ||
id, | ||
label, | ||
onClick, | ||
validation | ||
}: ApiButtonProps) => { | ||
const context = useContext(ApiFormContext); | ||
if (!context) throw new Error('ApiButton must be used within ApiForm'); | ||
|
||
const { store } = context; | ||
const [field, setField] = useAtom(store.fieldsAtom(id)); | ||
|
||
const loading = field.extra?.loading ?? false; | ||
const result = field.extra?.result; | ||
|
||
const setResult = (value: string) => { | ||
setField({ ...field, extra: { ...field.extra, result: value } }); | ||
}; | ||
|
||
const setLoading = (value: boolean) => { | ||
setField({ ...field, extra: { ...field.extra, loading: value } }); | ||
}; | ||
|
||
const { validate } = useValidation({ | ||
store, | ||
validation | ||
}); | ||
|
||
const handleClick = useCallback(async () => { | ||
setResult(undefined); | ||
|
||
const { isValid, error } = validate(); | ||
if (!isValid) { | ||
setResult(error || '验证失败'); | ||
return; | ||
} | ||
|
||
try { | ||
setLoading(true); | ||
await onClick(); | ||
} catch (error) { | ||
const errorMessage = get(error, 'message', 'error') ?? JSON.stringify(error); | ||
toast({ | ||
title: '执行失败', | ||
description: errorMessage, | ||
variant: 'destructive', | ||
}); | ||
setResult(errorMessage); | ||
} finally { | ||
setLoading(false); | ||
} | ||
}, [onClick, validate, setLoading, setResult]); | ||
|
||
return ( | ||
<div className="flex flex-col gap-1"> | ||
<Button | ||
key={id} | ||
onClick={handleClick} | ||
disabled={loading} | ||
loading={loading} | ||
> | ||
{label} | ||
</Button> | ||
{result && <div className="text-red-500 text-sm">{result}</div>} | ||
</div> | ||
); | ||
}); | ||
|
||
ApiButton.displayName = 'ApiButton'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import React, { memo, useContext, useEffect } from 'react'; | ||
import { useAtom } from 'jotai'; | ||
import { ApiFormContext } from './ApiForm'; | ||
import { Checkbox } from '../ui/checkbox'; | ||
|
||
export interface ApiCheckboxProps { | ||
id: string; | ||
label?: string; | ||
defaultChecked?: boolean; | ||
} | ||
|
||
export const ApiCheckbox = memo(({ | ||
id, | ||
label, | ||
defaultChecked | ||
}: ApiCheckboxProps) => { | ||
const context = useContext(ApiFormContext); | ||
if (!context) throw new Error('ApiField must be used within ApiForm'); | ||
|
||
const { store } = context; | ||
const [field, setField] = useAtom(store.fieldsAtom(id)); | ||
|
||
useEffect(() => { | ||
if (defaultChecked) { | ||
setField({ ...field, value: defaultChecked }); | ||
} | ||
}, []); | ||
|
||
return <div className="flex items-center gap-2"> | ||
<Checkbox | ||
id={id} | ||
defaultChecked={defaultChecked} | ||
required={field.required} | ||
checked={field.value} | ||
onCheckedChange={(e) => setField({ ...field, value: e })} | ||
disabled={field.disabled} | ||
/> | ||
|
||
<label | ||
htmlFor={id} | ||
className="p-0 m-0 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" | ||
> | ||
{label} | ||
</label> | ||
|
||
{field.error && ( | ||
<div className="text-sm text-red-500">{field.error}</div> | ||
)} | ||
</div> | ||
}); | ||
|
||
ApiCheckbox.displayName = 'ApiCheckbox'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo } from 'react'; | ||
import { useAtom } from 'jotai'; | ||
import { ApiFormContext } from './ApiForm'; | ||
import { Label } from '../ui/label'; | ||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; | ||
import { Button } from '../ui/button'; | ||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command'; | ||
import { ChevronDownIcon, CheckIcon } from 'lucide-react'; | ||
import { cn } from '../../lib/utils'; | ||
|
||
interface IOption<T> { | ||
value: string; | ||
label: string; | ||
extra?: T; | ||
remark?: string; | ||
} | ||
|
||
export interface ApiComboboxProps<T = any> { | ||
id: string; | ||
label?: string; | ||
required?: boolean; | ||
defaultValue?: string; | ||
placeholder?: string; | ||
onRequestOptions?: () => Promise<IOption<T>[]> | ||
onValueChange?: (value: string | null) => void; | ||
onOptionChange?: (option: IOption<T> | null) => void; | ||
} | ||
|
||
export interface ApiComboboxRef<T = any> { | ||
getCurrentValue: () => string | undefined; | ||
getCurrentOption: () => IOption<T> | undefined; | ||
getOptions: () => IOption<T>[]; | ||
setValue: (value: string) => void; | ||
setOptions: (options: IOption<T>[]) => void; | ||
} | ||
|
||
export const ApiCombobox = forwardRef<ApiComboboxRef, ApiComboboxProps>(function ApiCombobox<T = any>( | ||
{ | ||
id, | ||
label, | ||
required, | ||
defaultValue, | ||
placeholder, | ||
onRequestOptions, | ||
onValueChange, | ||
onOptionChange | ||
}: ApiComboboxProps<T>, | ||
ref: React.Ref<ApiComboboxRef<T>>, | ||
) { | ||
const context = useContext(ApiFormContext); | ||
if (!context) throw new Error('ApiField must be used within ApiForm'); | ||
|
||
const { store } = context; | ||
const [field, setField] = useAtom(store.fieldsAtom(id)); | ||
const options = field.extra?.options as IOption<T>[] | undefined; | ||
const [open, setOpen] = React.useState(false) | ||
|
||
const getCurrentOption = useCallback(() => { | ||
return options?.find(opt => opt.value === field.value); | ||
}, [options, field.value]); | ||
|
||
const currentOption = useMemo(() => getCurrentOption(), [getCurrentOption]); | ||
|
||
const setOptions = useCallback((options: IOption<T>[]) => { | ||
setField({ | ||
...field, extra: { | ||
options | ||
} | ||
}); | ||
}, [setField]); | ||
|
||
useEffect(() => { | ||
if (onRequestOptions) { | ||
onRequestOptions().then((options) => { | ||
setOptions(options); | ||
}); | ||
} | ||
}, [onRequestOptions]); | ||
|
||
const setValue = useCallback((value: string | null) => { | ||
setField({ ...field, value }); | ||
setOpen(false) | ||
onValueChange?.(value); | ||
onOptionChange?.(options?.find(opt => opt.value === value) ?? null); | ||
}, [setField, onValueChange, onOptionChange, options]); | ||
|
||
useEffect(() => { | ||
if (defaultValue) { | ||
setField({ ...field, value: defaultValue }); | ||
} | ||
}, []); | ||
|
||
useImperativeHandle(ref, () => ({ | ||
setValue, | ||
getCurrentValue: () => currentOption?.value, | ||
getCurrentOption: () => currentOption, | ||
getOptions: () => options, | ||
setOptions, | ||
}), [currentOption]); | ||
|
||
return <div className='flex flex-col'> | ||
{label && ( | ||
<Label htmlFor={id}> | ||
{label} | ||
{required && <span className="text-red-500">*</span>} | ||
</Label> | ||
)} | ||
<Popover open={open} onOpenChange={setOpen}> | ||
<PopoverTrigger asChild> | ||
<Button | ||
variant="outline" | ||
role="combobox" | ||
aria-expanded={open} | ||
className="w-full justify-between" | ||
> | ||
{currentOption?.value | ||
? options.find((option) => option.value === currentOption?.value)?.label | ||
: placeholder} | ||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> | ||
</Button> | ||
</PopoverTrigger> | ||
<PopoverContent className="w-[200px] p-0"> | ||
<Command> | ||
<CommandInput placeholder={placeholder} className="h-9" /> | ||
<CommandList> | ||
<CommandEmpty>没有找到选项</CommandEmpty> | ||
<CommandGroup> | ||
{options?.map((option) => ( | ||
<CommandItem | ||
key={option.value} | ||
value={option.label} | ||
onSelect={(currentLabel) => { | ||
const currentOption = options?.find(opt => opt.label === currentLabel); | ||
setValue(currentOption?.value ?? null); | ||
}} | ||
> | ||
{option.label} | ||
<CheckIcon | ||
className={cn( | ||
"ml-auto h-4 w-4", | ||
currentOption?.value === option.value ? "opacity-100" : "opacity-0" | ||
)} | ||
/> | ||
</CommandItem> | ||
))} | ||
</CommandGroup> | ||
</CommandList> | ||
</Command> | ||
</PopoverContent> | ||
</Popover> | ||
{currentOption?.remark && ( | ||
<span className="px-1 text-sm text-muted-foreground"> | ||
{currentOption.remark} | ||
</span> | ||
)} | ||
</div> | ||
}); | ||
|
||
ApiCombobox.displayName = 'ApiCombobox'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import React, { memo, useContext, useEffect } from 'react'; | ||
import { useAtom } from 'jotai'; | ||
import { Input } from '../ui/input'; | ||
import { Label } from '../ui/label'; | ||
import { ApiFormContext } from './ApiForm'; | ||
|
||
|
||
interface ApiInputProps { | ||
id: string; | ||
placeholder?: string; | ||
type?: 'text' | 'number' | 'password'; | ||
hidden?: boolean; | ||
defaultValue?: string; | ||
} | ||
|
||
const ApiInput = memo(({ | ||
id, | ||
placeholder, | ||
type = 'text', | ||
hidden = false, | ||
defaultValue | ||
}: ApiInputProps) => { | ||
|
||
const context = useContext(ApiFormContext); | ||
if (!context) throw new Error('ApiField must be used within ApiForm'); | ||
|
||
const { store } = context; | ||
const [field, setField] = useAtom(store.fieldsAtom(id)); | ||
|
||
useEffect(() => { | ||
if (defaultValue) { | ||
setField({ ...field, value: defaultValue }); | ||
} | ||
}, []); | ||
|
||
return <> | ||
<Input | ||
id={id} | ||
value={field.value} | ||
defaultValue={defaultValue} | ||
onChange={(e) => setField({ ...field, value: e.target.value })} | ||
placeholder={placeholder} | ||
disabled={field.disabled} | ||
type={type} | ||
hidden={hidden} | ||
/> | ||
{field.error && !hidden && ( | ||
<div className="text-sm text-red-500">{field.error}</div> | ||
)} | ||
</> | ||
}); | ||
|
||
export interface ApiFieldProps extends ApiInputProps { | ||
id: string; | ||
label?: string; | ||
required?: boolean; | ||
} | ||
|
||
export const ApiField = memo(({ | ||
id, | ||
label, | ||
placeholder, | ||
required, | ||
hidden = false, | ||
defaultValue | ||
}: ApiFieldProps) => { | ||
|
||
return ( | ||
<div> | ||
{label && !hidden && ( | ||
<Label htmlFor={id}> | ||
{label} | ||
{required && <span className="text-red-500">*</span>} | ||
</Label> | ||
)} | ||
<ApiInput id={id} placeholder={placeholder} hidden={hidden} defaultValue={defaultValue} /> | ||
</div> | ||
); | ||
}); | ||
|
||
ApiField.displayName = 'ApiField'; |
Oops, something went wrong.