From 43523a0bba1ef306aac8192126c3ed460ea9bace Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 10 Mar 2026 14:16:02 +0900 Subject: [PATCH] feat: Implement NOT NULL validation for form fields based on table metadata - Added a new function `isColumnRequired` to determine if a column is required based on its NOT NULL status from the table schema. - Updated the `SaveModal` and `InteractiveScreenViewer` components to incorporate this validation, ensuring that required fields are accurately assessed during form submission. - Enhanced the `DynamicComponentRenderer` to reflect the NOT NULL requirement in the component's required state. - Improved user feedback by marking required fields with an asterisk based on both manual settings and database constraints. These changes enhance the form validation process, ensuring that users are prompted for all necessary information based on the underlying data structure. --- .../screen/InteractiveScreenViewer.tsx | 22 +++++-- .../screen/InteractiveScreenViewerDynamic.tsx | 12 ++-- frontend/components/screen/SaveModal.tsx | 59 +++++++++++++------ .../screen/panels/V2PropertiesPanel.tsx | 38 ++++++++---- frontend/components/v2/V2Date.tsx | 3 +- frontend/components/v2/V2Hierarchy.tsx | 3 +- frontend/components/v2/V2Input.tsx | 3 +- frontend/components/v2/V2Media.tsx | 3 +- frontend/components/v2/V2Select.tsx | 3 +- .../lib/registry/DynamicComponentRenderer.tsx | 40 +++++++++---- .../components/v2-date/V2DateRenderer.tsx | 10 ++-- .../components/v2-input/V2InputRenderer.tsx | 9 +-- .../components/v2-select/V2SelectRenderer.tsx | 9 +-- 13 files changed, 138 insertions(+), 76 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4d8a6fec..56a7da33 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -245,6 +245,17 @@ export const InteractiveScreenViewer: React.FC = ( // 통합된 폼 데이터 const finalFormData = { ...localFormData, ...externalFormData }; + // 테이블 타입관리 NOT NULL 기반 필수 여부 판단 + const isColumnRequired = useCallback((columnName: string): boolean => { + if (!columnName || tableColumns.length === 0) return false; + const colInfo = tableColumns.find( + (c: any) => (c.columnName || c.column_name || "").toLowerCase() === columnName.toLowerCase() + ); + if (!colInfo) return false; + const nullable = (colInfo as any).isNullable || (colInfo as any).is_nullable; + return nullable === "NO" || nullable === "N"; + }, [tableColumns]); + // 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가) useEffect(() => { layers.forEach((layer) => { @@ -1612,8 +1623,11 @@ export const InteractiveScreenViewer: React.FC = ( return; } - // 필수 항목 검증 - const requiredFields = allComponents.filter(c => c.required && (c.columnName || c.id)); + // 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백) + const requiredFields = allComponents.filter(c => { + const colName = c.columnName || c.id; + return (c.required || isColumnRequired(colName)) && colName; + }); const missingFields = requiredFields.filter(field => { const fieldName = field.columnName || field.id; const value = currentFormData[fieldName]; @@ -2486,7 +2500,7 @@ export const InteractiveScreenViewer: React.FC = ( style={labelStyle} > {labelText} - {(component.required || component.componentConfig?.required) && *} + {(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && *} )} @@ -2505,7 +2519,7 @@ export const InteractiveScreenViewer: React.FC = ( }} > {labelText} - {(component.required || component.componentConfig?.required) && *} + {(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && *} )} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 1bb04e97..a22e0646 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -11,7 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent"; import { InteractiveDataTable } from "./InteractiveDataTable"; import { DynamicWebTypeRenderer } from "@/lib/registry"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; @@ -1294,9 +1294,8 @@ export const InteractiveScreenViewerDynamic: React.FC - {labelText} - {((component as any).required || (component as any).componentConfig?.required) && ( - * + {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( + * )} ) : null; @@ -1349,9 +1348,8 @@ export const InteractiveScreenViewerDynamic: React.FC - {labelText} - {((component as any).required || (component as any).componentConfig?.required) && ( - * + {labelText}{((component as any).required || (component as any).componentConfig?.required || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && ( + * )}
diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 259bf238..6a4e858f 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -10,6 +10,7 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { screenApi } from "@/lib/api/screen"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; +import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement"; import { ComponentData } from "@/lib/types/screen"; import { useAuth } from "@/hooks/useAuth"; @@ -42,6 +43,7 @@ export const SaveModal: React.FC = ({ const [originalData, setOriginalData] = useState>(initialData || {}); const [screenData, setScreenData] = useState(null); const [components, setComponents] = useState([]); + const [tableColumnsInfo, setTableColumnsInfo] = useState([]); const [loading, setLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -70,6 +72,19 @@ export const SaveModal: React.FC = ({ const layout = await screenApi.getLayout(screenId); setComponents(layout.components || []); + // 테이블 컬럼 정보 로드 (NOT NULL 필수값 자동 인식용) + const tblName = screen?.tableName || layout.components?.find((c: any) => c.columnName)?.tableName; + if (tblName) { + try { + const colResult = await getTableColumns(tblName); + if (colResult.success && colResult.data?.columns) { + setTableColumnsInfo(colResult.data.columns); + } + } catch (colErr) { + console.warn("테이블 컬럼 정보 로드 실패 (필수값 검증 시 기존 방식 사용):", colErr); + } + } + // initialData가 있으면 폼에 채우기 if (initialData) { setFormData(initialData); @@ -106,34 +121,39 @@ export const SaveModal: React.FC = ({ }; }, [onClose]); - // 필수 항목 검증 + // 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단 + const isColumnRequired = (columnName: string): boolean => { + if (!columnName || tableColumnsInfo.length === 0) return false; + const colInfo = tableColumnsInfo.find( + (c) => c.columnName.toLowerCase() === columnName.toLowerCase() + ); + if (!colInfo) return false; + // is_nullable가 "NO"이면 필수 + return colInfo.isNullable === "NO" || colInfo.isNullable === "N"; + }; + + // 필수 항목 검증 (테이블 타입관리 NOT NULL + 기존 required 속성 병합) const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => { const missingFields: string[] = []; components.forEach((component) => { - // 컴포넌트의 required 속성 확인 (여러 위치에서 체크) - const isRequired = - component.required === true || - component.style?.required === true || - component.componentConfig?.required === true; - const columnName = component.columnName || component.style?.columnName; const label = component.label || component.style?.label || columnName; - console.log("🔍 필수 항목 검증:", { - componentId: component.id, - columnName, - label, - isRequired, - "component.required": component.required, - "style.required": component.style?.required, - "componentConfig.required": component.componentConfig?.required, - value: formData[columnName || ""], - }); + // 기존 required 속성 (화면 디자이너에서 수동 설정한 것) + const manualRequired = + component.required === true || + component.style?.required === true || + component.componentConfig?.required === true; + + // 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만) + const notNullRequired = columnName ? isColumnRequired(columnName) : false; + + // 둘 중 하나라도 필수이면 검증 + const isRequired = manualRequired || notNullRequired; if (isRequired && columnName) { const value = formData[columnName]; - // 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열) if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { missingFields.push(label || columnName); } @@ -405,7 +425,8 @@ export const SaveModal: React.FC = ({ })); }} hideLabel={false} - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용) + menuObjid={menuObjid} + tableColumns={tableColumnsInfo as any} /> ) : ( = ({ {/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
- {(isInputField || widget.required !== undefined) && ( -
- { - handleUpdate("required", checked); - handleUpdate("componentConfig.required", checked); - }} - className="h-4 w-4" - /> - -
- )} + {(isInputField || widget.required !== undefined) && (() => { + const colName = widget.columnName || selectedComponent?.columnName; + const colMeta = colName ? currentTable?.columns?.find( + (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase() + ) : null; + const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N"); + return ( +
+ { + if (isNotNull) return; + handleUpdate("required", checked); + handleUpdate("componentConfig.required", checked); + }} + disabled={!!isNotNull} + className="h-4 w-4" + /> + +
+ ); + })()} {(isInputField || widget.readonly !== undefined) && (
((props, ref) => { }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} ) : null; diff --git a/frontend/components/v2/V2Hierarchy.tsx b/frontend/components/v2/V2Hierarchy.tsx index 28f51fee..f0a371fa 100644 --- a/frontend/components/v2/V2Hierarchy.tsx +++ b/frontend/components/v2/V2Hierarchy.tsx @@ -491,8 +491,7 @@ export const V2Hierarchy = forwardRef( }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} )}
diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 6bd19c5d..6708a42b 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -994,8 +994,7 @@ export const V2Input = forwardRef((props, ref) => }} className="text-sm font-medium whitespace-nowrap" > - {actualLabel} - {required && *} + {actualLabel}{required && *} ) : null; diff --git a/frontend/components/v2/V2Media.tsx b/frontend/components/v2/V2Media.tsx index 0a4faaae..e9f152fb 100644 --- a/frontend/components/v2/V2Media.tsx +++ b/frontend/components/v2/V2Media.tsx @@ -840,8 +840,7 @@ export const V2Media = forwardRef((props, ref) => }} className="shrink-0 text-sm font-medium" > - {label} - {required && *} + {label}{required && *} )} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 538d33be..0b8720f8 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1181,8 +1181,7 @@ export const V2Select = forwardRef( }} className="text-sm font-medium whitespace-nowrap" > - {label} - {required && *} + {label}{required && *} ) : null; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 50c4bee4..3012fa4c 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -15,7 +15,13 @@ const columnMetaCache: Record> = {}; const columnMetaLoading: Record> = {}; async function loadColumnMeta(tableName: string): Promise { - if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return; + if (columnMetaCache[tableName]) return; + + // 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지) + if (columnMetaLoading[tableName]) { + await columnMetaLoading[tableName]; + return; + } columnMetaLoading[tableName] = (async () => { try { @@ -28,7 +34,8 @@ async function loadColumnMeta(tableName: string): Promise { if (name) map[name] = col; } columnMetaCache[tableName] = map; - } catch { + } catch (e) { + console.error(`[columnMeta] ${tableName} 로드 실패:`, e); columnMetaCache[tableName] = {}; } finally { delete columnMetaLoading[tableName]; @@ -38,6 +45,15 @@ async function loadColumnMeta(tableName: string): Promise { await columnMetaLoading[tableName]; } +// 테이블 타입관리 NOT NULL 기반 필수 여부 판단 +export function isColumnRequiredByMeta(tableName?: string, columnName?: string): boolean { + if (!tableName || !columnName) return false; + const meta = columnMetaCache[tableName]?.[columnName]; + if (!meta) return false; + const nullable = meta.is_nullable || meta.isNullable; + return nullable === "NO" || nullable === "N"; +} + // table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { if (!tableName || !columnName) return componentConfig; @@ -249,7 +265,7 @@ export const DynamicComponentRenderer: React.FC = const screenTableName = props.tableName || (component as any).tableName; const [, forceUpdate] = React.useState(0); React.useEffect(() => { - if (screenTableName && !columnMetaCache[screenTableName]) { + if (screenTableName) { loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); } }, [screenTableName]); @@ -435,7 +451,7 @@ export const DynamicComponentRenderer: React.FC = const labelFontSize = component.style?.labelFontSize || "14px"; const labelColor = component.style?.labelColor || "#64748b"; const labelFontWeight = component.style?.labelFontWeight || "500"; - const isRequired = component.required || (component as any).required; + const isRequired = component.required || (component as any).required || isColumnRequiredByMeta(tableName, columnName); const isLeft = catLabelPosition === "left"; return (
@@ -454,8 +470,7 @@ export const DynamicComponentRenderer: React.FC = }} className="text-sm font-medium" > - {catLabelText} - {isRequired && *} + {catLabelText}{isRequired && *}
{renderedCatSelect} @@ -715,10 +730,14 @@ export const DynamicComponentRenderer: React.FC = const baseColumnName = isEntityJoinColumn ? undefined : fieldName; const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + // NOT NULL 기반 필수 여부를 component.required에 반영 + const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName); + const effectiveRequired = component.required || notNullRequired; + // 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제 const effectiveComponent = isEntityJoinColumn - ? { ...component, componentConfig: mergedComponentConfig, readonly: false } - : { ...component, componentConfig: mergedComponentConfig }; + ? { ...component, componentConfig: mergedComponentConfig, readonly: false, required: effectiveRequired } + : { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired }; const rendererProps = { component: effectiveComponent, @@ -852,7 +871,7 @@ export const DynamicComponentRenderer: React.FC = const labelFontSize = component.style?.labelFontSize || "14px"; const labelColor = component.style?.labelColor || "#64748b"; const labelFontWeight = component.style?.labelFontWeight || "500"; - const isRequired = component.required || (component as any).required; + const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); const isLeft = labelPosition === "left"; return ( @@ -872,8 +891,7 @@ export const DynamicComponentRenderer: React.FC = }} className="text-sm font-medium" > - {effectiveLabel} - {isRequired && *} + {effectiveLabel}{isRequired && *}
{renderedElement} diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx index 1550bbe3..46fe4d7e 100644 --- a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -4,6 +4,7 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2DateDefinition } from "./index"; import { V2Date } from "@/components/v2/V2Date"; +import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; /** * V2Date 렌더러 @@ -18,6 +19,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer { // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; + const tableName = component.tableName || this.props.tableName; // formData에서 현재 값 가져오기 const currentValue = formData?.[columnName] ?? component.value ?? ""; @@ -37,10 +39,6 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer { return ( ); } diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index eba973e4..90c4f801 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -4,6 +4,7 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; +import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; /** * V2Input 렌더러 @@ -52,10 +53,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { return ( ); } diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index b389cff7..36e35eac 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -4,6 +4,7 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2SelectDefinition } from "./index"; import { V2Select } from "@/components/v2/V2Select"; +import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; /** * V2Select 렌더러 @@ -112,10 +113,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { return ( ); }