Merge remote-tracking branch 'origin/ycshin-node' into ycshin-node

Resolve conflict in InteractiveScreenViewerDynamic.tsx:
- Keep horizontal label code (fd5c61b side)
- Remove old inline required field validation (replaced by useDialogAutoValidation hook)
- Clean up checkAllRequiredFieldsFilled usage from SaveModal, ButtonPrimaryComponent
- Remove isFieldEmpty, isInputComponent, checkAllRequiredFieldsFilled from formValidation.ts

Made-with: Cursor
This commit is contained in:
syc0123
2026-03-03 13:12:48 +09:00
38 changed files with 1642 additions and 525 deletions

View File

@@ -14,7 +14,6 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { isFieldEmpty } from "@/lib/utils/formValidation";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
@@ -1111,7 +1110,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
(style?.labelText || (component as any).label);
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
@@ -1121,41 +1120,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
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);
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
@@ -1232,9 +1201,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = {
position: "absolute" as const,
...safeStyleWithoutSize,
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
@@ -1242,7 +1219,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
width: isSplitActive ? adjustedW : (size?.width || 200),
height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined,
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : ((labelOffset > 0 || showRequiredError) ? "visible" : undefined),
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: isSplitActive
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
@@ -1305,11 +1282,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe;
}, [component.id, position?.x, size?.width, type]);
// 라벨 위치가 top이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const externalLabelComponent = needsExternalLabel ? (
<label
@@ -1330,36 +1303,80 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
) : null;
const componentToRender = needsExternalLabel
? { ...splitAdjustedComponent, style: { ...splitAdjustedComponent.style, labelDisplay: false } }
? {
...splitAdjustedComponent,
style: {
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? {
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
},
...(isHorizLabel ? {
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: splitAdjustedComponent;
return (
<>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{needsExternalLabel ? (
<div
style={{
display: "flex",
flexDirection: isHorizLabel ? (labelPos === "left" ? "row" : "row-reverse") : "column-reverse",
alignItems: isHorizLabel ? "center" : undefined,
gap: isHorizLabel ? labelGapValue : undefined,
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0, height: isHorizLabel ? "100%" : undefined }}>
{renderInteractiveWidget(componentToRender)}
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
className="text-sm font-medium leading-none"
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(labelPos === "left"
? { right: "100%", marginRight: labelGapValue }
: { left: "100%", marginLeft: labelGapValue }),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column-reverse",
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
)
) : (
renderInteractiveWidget(componentToRender)
)}
{showRequiredError && (
<p className="text-destructive pointer-events-none absolute left-0 text-[11px] leading-tight" style={{ top: "100%", marginTop: 2 }}>
</p>
)}
</div>
{/* 팝업 화면 렌더링 */}