feat: Enhance form validation and modal handling in various components
- Added `isInModal` prop to `ScreenModal` and `InteractiveScreenViewerDynamic` for improved modal context awareness. - Implemented `isFieldEmpty` and `checkAllRequiredFieldsFilled` utility functions to validate required fields in forms. - Updated `SaveModal` and `ButtonPrimaryComponent` to disable save actions when required fields are missing, enhancing user feedback. - Introduced error messages for required fields in modals to guide users in completing necessary inputs. Made-with: Cursor
This commit is contained in:
parent
dc04bd162a
commit
83437e76dd
|
|
@ -1218,6 +1218,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={user?.companyCode}
|
companyCode={user?.companyCode}
|
||||||
|
isInModal={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -1261,6 +1262,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={user?.companyCode}
|
companyCode={user?.companyCode}
|
||||||
|
isInModal={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
|
import { isFieldEmpty } from "@/lib/utils/formValidation";
|
||||||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||||
|
|
@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// buttonActions.ts가 이미 처리함
|
// buttonActions.ts가 이미 처리함
|
||||||
}}
|
}}
|
||||||
|
isInModal={isInModal}
|
||||||
// 탭 관련 정보 전달
|
// 탭 관련 정보 전달
|
||||||
parentTabId={parentTabId}
|
parentTabId={parentTabId}
|
||||||
parentTabsComponentId={parentTabsComponentId}
|
parentTabsComponentId={parentTabsComponentId}
|
||||||
|
|
@ -1119,6 +1121,42 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||||
|
|
||||||
|
// 필수 입력값 검증 (모달 내부에서만 에러 표시)
|
||||||
|
const reqCompType = component.type;
|
||||||
|
const reqCompComponentType = (component as any).componentType || "";
|
||||||
|
const isInputLikeComponent = reqCompType === "widget" || (
|
||||||
|
reqCompType === "component" && (
|
||||||
|
reqCompComponentType.startsWith("v2-input") ||
|
||||||
|
reqCompComponentType.startsWith("v2-select") ||
|
||||||
|
reqCompComponentType.startsWith("v2-date") ||
|
||||||
|
reqCompComponentType.startsWith("v2-textarea") ||
|
||||||
|
reqCompComponentType.startsWith("v2-number") ||
|
||||||
|
reqCompComponentType === "entity-search-input" ||
|
||||||
|
reqCompComponentType === "autocomplete-search-input"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const isRequiredWidget = isInputLikeComponent && (
|
||||||
|
(component as any).required === true ||
|
||||||
|
(style as any)?.required === true ||
|
||||||
|
(component as any).componentConfig?.required === true ||
|
||||||
|
(component as any).overrides?.required === true
|
||||||
|
);
|
||||||
|
const isAutoInputField =
|
||||||
|
(component as any).inputType === "auto" ||
|
||||||
|
(component as any).componentConfig?.inputType === "auto" ||
|
||||||
|
(component as any).overrides?.inputType === "auto";
|
||||||
|
const isReadonlyWidget =
|
||||||
|
(component as any).readonly === true ||
|
||||||
|
(component as any).componentConfig?.readonly === true ||
|
||||||
|
(component as any).overrides?.readonly === true;
|
||||||
|
const requiredFieldName =
|
||||||
|
(component as any).columnName ||
|
||||||
|
(component as any).componentConfig?.columnName ||
|
||||||
|
(component as any).overrides?.columnName ||
|
||||||
|
component.id;
|
||||||
|
const requiredFieldValue = formData[requiredFieldName];
|
||||||
|
const showRequiredError = isInModal && isRequiredWidget && !isAutoInputField && !isReadonlyWidget && isFieldEmpty(requiredFieldValue);
|
||||||
|
|
||||||
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
||||||
const compType = (component as any).componentType || "";
|
const compType = (component as any).componentType || "";
|
||||||
const isSplitLine = type === "component" && compType === "v2-split-line";
|
const isSplitLine = type === "component" && compType === "v2-split-line";
|
||||||
|
|
@ -1204,7 +1242,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
width: isSplitActive ? adjustedW : (size?.width || 200),
|
width: isSplitActive ? adjustedW : (size?.width || 200),
|
||||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||||
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
|
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : ((labelOffset > 0 || showRequiredError) ? "visible" : undefined),
|
||||||
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
||||||
transition: isSplitActive
|
transition: isSplitActive
|
||||||
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
|
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
|
||||||
|
|
@ -1317,6 +1355,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
) : (
|
) : (
|
||||||
renderInteractiveWidget(componentToRender)
|
renderInteractiveWidget(componentToRender)
|
||||||
)}
|
)}
|
||||||
|
{showRequiredError && (
|
||||||
|
<p className="text-destructive pointer-events-none absolute left-0 text-[11px] leading-tight" style={{ top: "100%", marginTop: 2 }}>
|
||||||
|
필수 입력 항목입니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 팝업 화면 렌더링 */}
|
{/* 팝업 화면 렌더링 */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { screenApi } from "@/lib/api/screen";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { ComponentData } from "@/lib/types/screen";
|
import { ComponentData } from "@/lib/types/screen";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
|
||||||
|
|
||||||
interface SaveModalProps {
|
interface SaveModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -304,6 +305,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const dynamicSize = calculateDynamicSize();
|
const dynamicSize = calculateDynamicSize();
|
||||||
|
const isRequiredFieldsMissing = !checkAllRequiredFieldsFilled(components, formData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||||
|
|
@ -320,7 +322,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || isRequiredFieldsMissing}
|
||||||
|
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
|
|
@ -1258,9 +1259,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
|
// 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화
|
||||||
|
const isInModalContext = (props as any).isInModal === true;
|
||||||
|
const isSaveAction = processedConfig.action?.type === "save";
|
||||||
|
const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents
|
||||||
|
? !checkAllRequiredFieldsFilled(allComponents, formData || {})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력)
|
||||||
const finalDisabled =
|
const finalDisabled =
|
||||||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing;
|
||||||
|
|
||||||
// 공통 버튼 스타일
|
// 공통 버튼 스타일
|
||||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
// 🔧 component.style에서 background/backgroundColor 충돌 방지
|
||||||
|
|
@ -1317,6 +1325,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={finalDisabled}
|
disabled={finalDisabled}
|
||||||
|
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
|
||||||
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
||||||
style={buttonElementStyle}
|
style={buttonElementStyle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC
|
||||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||||
|
import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
|
|
@ -1216,15 +1217,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
effectiveFormData = { ...splitPanelParentData };
|
effectiveFormData = { ...splitPanelParentData };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
|
|
||||||
propsFormDataKeys: Object.keys(propsFormData),
|
|
||||||
screenContextFormDataKeys: Object.keys(screenContextFormData),
|
|
||||||
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
|
||||||
process_code: effectiveFormData.process_code,
|
|
||||||
equipment_code: effectiveFormData.equipment_code,
|
|
||||||
fullData: JSON.stringify(effectiveFormData),
|
|
||||||
});
|
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: effectiveFormData,
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
|
|
@ -1381,9 +1373,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
|
// 모달 내 저장 버튼: 필수 필드 미입력 시 비활성화
|
||||||
|
const isInModalContext = (props as any).isInModal === true;
|
||||||
|
const isSaveAction = processedConfig.action?.type === "save";
|
||||||
|
const isRequiredFieldsMissing = isSaveAction && isInModalContext && allComponents
|
||||||
|
? !checkAllRequiredFieldsFilled(allComponents, formData || {})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수 + 필수 필드 미입력)
|
||||||
const finalDisabled =
|
const finalDisabled =
|
||||||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading || isRequiredFieldsMissing;
|
||||||
|
|
||||||
// 공통 버튼 스타일
|
// 공통 버튼 스타일
|
||||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
||||||
|
|
@ -1470,6 +1469,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={finalDisabled}
|
disabled={finalDisabled}
|
||||||
|
title={isRequiredFieldsMissing ? "필수 입력 항목을 모두 채워주세요" : undefined}
|
||||||
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
|
||||||
style={buttonElementStyle}
|
style={buttonElementStyle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
|
||||||
|
|
@ -661,3 +661,76 @@ const calculateStringSimilarity = (str1: string, str2: string): number => {
|
||||||
|
|
||||||
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
|
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값이 비어있는지 판별
|
||||||
|
*/
|
||||||
|
export const isFieldEmpty = (value: any): boolean => {
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
value === undefined ||
|
||||||
|
(typeof value === "string" && value.trim() === "") ||
|
||||||
|
(Array.isArray(value) && value.length === 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 입력 가능한 컴포넌트인지 판별 (위젯 또는 V2 입력 컴포넌트)
|
||||||
|
*/
|
||||||
|
const isInputComponent = (comp: any): boolean => {
|
||||||
|
if (comp.type === "widget") return true;
|
||||||
|
|
||||||
|
if (comp.type === "component") {
|
||||||
|
const ct = comp.componentType || "";
|
||||||
|
return ct.startsWith("v2-input") ||
|
||||||
|
ct.startsWith("v2-select") ||
|
||||||
|
ct.startsWith("v2-date") ||
|
||||||
|
ct.startsWith("v2-textarea") ||
|
||||||
|
ct.startsWith("v2-number") ||
|
||||||
|
ct === "entity-search-input" ||
|
||||||
|
ct === "autocomplete-search-input";
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 필수 필드가 채워졌는지 판별 (모달 저장 버튼 비활성화용)
|
||||||
|
* auto 입력 필드, readonly 필드는 검증 제외
|
||||||
|
*/
|
||||||
|
export const checkAllRequiredFieldsFilled = (
|
||||||
|
allComponents: any[],
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): boolean => {
|
||||||
|
for (const comp of allComponents) {
|
||||||
|
if (!isInputComponent(comp)) continue;
|
||||||
|
|
||||||
|
const isRequired =
|
||||||
|
comp.required === true ||
|
||||||
|
comp.style?.required === true ||
|
||||||
|
comp.componentConfig?.required === true ||
|
||||||
|
comp.overrides?.required === true;
|
||||||
|
if (!isRequired) continue;
|
||||||
|
|
||||||
|
const isAutoInput =
|
||||||
|
comp.inputType === "auto" ||
|
||||||
|
comp.componentConfig?.inputType === "auto" ||
|
||||||
|
comp.overrides?.inputType === "auto";
|
||||||
|
const isReadonly =
|
||||||
|
comp.readonly === true ||
|
||||||
|
comp.componentConfig?.readonly === true ||
|
||||||
|
comp.overrides?.readonly === true;
|
||||||
|
if (isAutoInput || isReadonly) continue;
|
||||||
|
|
||||||
|
const fieldName =
|
||||||
|
comp.columnName ||
|
||||||
|
comp.componentConfig?.columnName ||
|
||||||
|
comp.overrides?.columnName ||
|
||||||
|
comp.id;
|
||||||
|
const value = formData[fieldName];
|
||||||
|
|
||||||
|
if (isFieldEmpty(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue