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,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -42,6 +44,7 @@ import { TableSectionRenderer } from "./TableSectionRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||||
|
* allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||||
*/
|
*/
|
||||||
interface CascadingSelectFieldProps {
|
interface CascadingSelectFieldProps {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
|
|
@ -51,6 +54,7 @@ interface CascadingSelectFieldProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
allowCustomInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
|
@ -61,12 +65,20 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled,
|
disabled,
|
||||||
|
allowCustomInput = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || "");
|
||||||
const { options, loading } = useCascadingDropdown({
|
const { options, loading } = useCascadingDropdown({
|
||||||
config,
|
config,
|
||||||
parentValue,
|
parentValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// value가 외부에서 변경되면 inputValue도 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value || "");
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const getPlaceholder = () => {
|
const getPlaceholder = () => {
|
||||||
if (!parentValue) {
|
if (!parentValue) {
|
||||||
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
||||||
|
|
@ -82,6 +94,79 @@ const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
|
||||||
|
|
||||||
const isDisabled = disabled || !parentValue || loading;
|
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 (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
<Select value={value || ""} onValueChange={onChange} disabled={isDisabled}>
|
||||||
<SelectTrigger id={fieldId} className="w-full" size="default">
|
<SelectTrigger id={fieldId} className="w-full" size="default">
|
||||||
|
|
@ -1503,6 +1588,7 @@ export function UniversalFormModalComponent({
|
||||||
onChange={onChangeHandler}
|
onChange={onChangeHandler}
|
||||||
placeholder={field.placeholder || "선택하세요"}
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1534,6 +1620,7 @@ export function UniversalFormModalComponent({
|
||||||
onChange={onChangeHandler}
|
onChange={onChangeHandler}
|
||||||
placeholder={field.placeholder || "선택하세요"}
|
placeholder={field.placeholder || "선택하세요"}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
allowCustomInput={field.selectOptions?.allowCustomInput}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2243,6 +2330,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select 필드 컴포넌트 (옵션 로딩 포함)
|
// Select 필드 컴포넌트 (옵션 로딩 포함)
|
||||||
|
// allowCustomInput이 true이면 Combobox 형태로 직접 입력 가능
|
||||||
interface SelectFieldProps {
|
interface SelectFieldProps {
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
value: any;
|
value: any;
|
||||||
|
|
@ -2256,6 +2344,10 @@ interface SelectFieldProps {
|
||||||
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
|
function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disabled, loadOptions }: SelectFieldProps) {
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || "");
|
||||||
|
|
||||||
|
const allowCustomInput = optionConfig?.allowCustomInput || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (optionConfig) {
|
if (optionConfig) {
|
||||||
|
|
@ -2266,6 +2358,82 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
||||||
}
|
}
|
||||||
}, [fieldId, optionConfig, loadOptions]);
|
}, [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 (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
<SelectTrigger size="default">
|
<SelectTrigger size="default">
|
||||||
|
|
|
||||||
|
|
@ -480,6 +480,31 @@ export function FieldDetailSettingsModal({
|
||||||
</HelpText>
|
</HelpText>
|
||||||
</div>
|
</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" && (
|
{localField.selectOptions?.type === "table" && (
|
||||||
<div className="space-y-3 pt-2 border-t">
|
<div className="space-y-3 pt-2 border-t">
|
||||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ export interface SelectOptionConfig {
|
||||||
noOptionsMessage?: string; // 옵션 없음 메시지
|
noOptionsMessage?: string; // 옵션 없음 메시지
|
||||||
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
|
clearOnParentChange?: boolean; // 부모 변경 시 값 초기화 (기본: true)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 직접 입력 허용 (모든 Select 타입에 공통 적용)
|
||||||
|
// true: Combobox 형태로 목록 선택 + 직접 입력 가능
|
||||||
|
// false/undefined: 기본 Select 형태로 목록에서만 선택 가능
|
||||||
|
allowCustomInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번규칙 설정
|
// 채번규칙 설정
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue