feat(universal-form-modal): Select 필드 직접 입력(Combobox) 모드 추가
SelectOptionConfig에 allowCustomInput 옵션 추가 FieldDetailSettingsModal에 "직접 입력 허용" Switch UI 추가 CascadingSelectField에 Combobox 모드 구현 (Popover+Command) SelectField에 Combobox 모드 구현 목록 선택과 직접 입력 동시 지원
This commit is contained in:
parent
ef27e0e38f
commit
cf97db7fbf
|
|
@ -19,7 +19,9 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -42,6 +44,7 @@ import { TableSectionRenderer } from "./TableSectionRenderer";
|
|||
|
||||
/**
|
||||
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||
* allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||
*/
|
||||
interface CascadingSelectFieldProps {
|
||||
fieldId: string;
|
||||
|
|
@ -51,6 +54,7 @@ interface CascadingSelectFieldProps {
|
|||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
allowCustomInput?: boolean;
|
||||
}
|
||||
|
||||
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||
|
|
@ -61,12 +65,20 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
allowCustomInput = false,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value || "");
|
||||
const { options, loading } = useCascadingDropdown({
|
||||
config,
|
||||
parentValue,
|
||||
});
|
||||
|
||||
// value가 외부에서 변경되면 inputValue도 동기화
|
||||
useEffect(() => {
|
||||
setInputValue(value || "");
|
||||
}, [value]);
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (!parentValue) {
|
||||
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||
|
|
@ -82,6 +94,79 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
|||
|
||||
const isDisabled = disabled || !parentValue || loading;
|
||||
|
||||
// Combobox 형태 (직접 입력 허용)
|
||||
if (allowCustomInput) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder={getPlaceholder()}
|
||||
disabled={isDisabled}
|
||||
className="w-full pr-8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||
onClick={() => !isDisabled && setOpen(!open)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{!parentValue
|
||||
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
||||
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options
|
||||
.filter((option) => option.value && option.value !== "")
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
setInputValue(option.label);
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 Select 형태 (목록에서만 선택)
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||
<SelectTrigger id={fieldId} className="w-full" size="default">
|
||||
|
|
@ -1503,6 +1588,7 @@ export function UniversalFormModalComponent({
|
|||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "선택하세요"}
|
||||
disabled={isDisabled}
|
||||
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1534,6 +1620,7 @@ export function UniversalFormModalComponent({
|
|||
onChange={onChangeHandler}
|
||||
placeholder={field.placeholder || "선택하세요"}
|
||||
disabled={isDisabled}
|
||||
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -2243,6 +2330,7 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
// Select 필드 컴포넌트 (옵션 로딩 포함)
|
||||
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||
interface SelectFieldProps {
|
||||
fieldId: string;
|
||||
value: any;
|
||||
|
|
@ -2256,6 +2344,10 @@ interface SelectFieldProps {
|
|||
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value || "");
|
||||
|
||||
const allowCustomInput = optionConfig?.allowCustomInput || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (optionConfig) {
|
||||
|
|
@ -2266,6 +2358,82 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
}
|
||||
}, [fieldId, optionConfig, loadOptions]);
|
||||
|
||||
// value가 외부에서 변경되면 inputValue도 동기화
|
||||
useEffect(() => {
|
||||
// 선택된 값이 있으면 해당 라벨을 표시, 없으면 value 그대로 표시
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
setInputValue(selectedOption ? selectedOption.label : value || "");
|
||||
}, [value, options]);
|
||||
|
||||
// Combobox 형태 (직접 입력 허용)
|
||||
if (allowCustomInput) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
placeholder={loading ? "로딩 중..." : placeholder}
|
||||
disabled={disabled || loading}
|
||||
className="w-full pr-8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-2 hover:bg-transparent"
|
||||
onClick={() => !disabled && !loading && setOpen(!open)}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>선택 가능한 항목이 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options
|
||||
.filter((option) => option.value && option.value !== "")
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
setInputValue(option.label);
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 Select 형태 (목록에서만 선택)
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
||||
<SelectTrigger size="default">
|
||||
|
|
|
|||
|
|
@ -480,6 +480,31 @@ export function FieldDetailSettingsModal({
|
|||
</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 직접 입력 허용 - 모든 Select 타입에 공통 적용 */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-medium">직접 입력 허용</span>
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
목록 선택 + 직접 타이핑 가능
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localField.selectOptions?.allowCustomInput || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField({
|
||||
selectOptions: {
|
||||
...localField.selectOptions,
|
||||
allowCustomInput: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
활성화 시 드롭다운 목록에서 선택하거나, 직접 값을 입력할 수 있습니다.
|
||||
목록에 없는 새로운 값도 입력 가능합니다.
|
||||
</HelpText>
|
||||
|
||||
{localField.selectOptions?.type === "table" && (
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ export interface SelectOptionConfig {
|
|||
noOptionsMessage?: string; // 옵션 없음 메시지
|
||||
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
|
||||
};
|
||||
|
||||
// 직접 입력 허용 (모든 Select 타입에 공통 적용)
|
||||
// true: Combobox 형태로 목록 선택 + 직접 입력 가능
|
||||
// false/undefined: 기본 Select 형태로 목록에서만 선택 가능
|
||||
allowCustomInput?: boolean;
|
||||
}
|
||||
|
||||
// 채번규칙 설정
|
||||
|
|
|
|||
Loading…
Reference in New Issue