Files
invyone/frontend/lib/registry/components/container/ContainerComponent.tsx
T
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

603 lines
22 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import {
ContainerConfig,
ContainerType,
ContainerTab,
ContainerChildComponent,
} from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
/**
* Container — 통합 레이아웃 컨테이너 컴포넌트
*
* containerType 으로 탭/섹션/아코디언/반복/조건부 분기.
*
* - Phase C-2 최소 구현: 각 모드는 스켈레톤 렌더.
* - Phase G.2: `containerType === "tabs"` 만 활성 탭의 자식 컴포넌트들을
* `DynamicComponentRenderer` 로 렌더. 비활성 탭은 렌더하지 않음 (성능).
* section / accordion / repeater / conditional 은 여전히 스켈레톤 — 별도 phase.
*/
const VALID_TYPES: ContainerType[] = [
"tabs",
"section",
"accordion",
"repeater",
"conditional",
];
export interface ContainerComponentProps extends ComponentRendererProps {
config?: ContainerConfig;
}
export const ContainerComponent: React.FC<ContainerComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
...props
}) => {
const fromProps: Partial<ContainerConfig> = {};
const p = props as any;
if (typeof p.containerType === "string" && (VALID_TYPES as string[]).includes(p.containerType))
fromProps.containerType = p.containerType as ContainerType;
if (typeof p.title === "string") fromProps.title = p.title;
if (Array.isArray(p.tabs)) fromProps.tabs = p.tabs;
if (typeof p.defaultTab === "string") fromProps.defaultTab = p.defaultTab;
if (typeof p.sectionVariant === "string") fromProps.sectionVariant = p.sectionVariant;
if (typeof p.collapsible === "boolean") fromProps.collapsible = p.collapsible;
if (typeof p.defaultCollapsed === "boolean") fromProps.defaultCollapsed = p.defaultCollapsed;
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
if (typeof p.minRows === "number") fromProps.minRows = p.minRows;
if (typeof p.maxRows === "number") fromProps.maxRows = p.maxRows;
if (typeof p.addRowText === "string") fromProps.addRowText = p.addRowText;
if (typeof p.conditionField === "string") fromProps.conditionField = p.conditionField;
if (typeof p.conditionOperator === "string") fromProps.conditionOperator = p.conditionOperator;
if (typeof p.conditionValue === "string") fromProps.conditionValue = p.conditionValue;
if (typeof p.padding === "string") fromProps.padding = p.padding;
if (typeof p.transparent === "boolean") fromProps.transparent = p.transparent;
const componentConfig = {
...config,
...((component as any).config ?? {}),
...((component as any).component_config ?? {}),
...((component as any).componentConfig ?? {}),
...fromProps,
} as ContainerConfig;
const rawComponentType =
(component as any).componentType ??
(component as any).component_type ??
(component as any).componentId ??
(component as any).component_id ??
(component as any).type;
if (
componentConfig.containerType == null &&
(rawComponentType === "v2-tabs-widget" ||
rawComponentType === "tabs-widget" ||
rawComponentType === "tabs" ||
rawComponentType === "v2-tabs" ||
Array.isArray(componentConfig.tabs))
) {
componentConfig.containerType = "tabs";
}
const containerType: ContainerType = (VALID_TYPES as string[]).includes(
componentConfig.containerType as string,
)
? (componentConfig.containerType as ContainerType)
: "section";
const title = componentConfig.title;
const padding = componentConfig.padding ?? "12px";
const transparent = componentConfig.transparent ?? false;
const containerStyle: React.CSSProperties = {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
background: transparent ? "transparent" : "hsl(var(--card))",
borderRadius: "8px",
border: transparent ? "1px dashed hsl(var(--border))" : "1px solid hsl(var(--border))",
overflow: "hidden",
...(component as any).style,
...style,
};
if (isDesignMode && isSelected) {
containerStyle.outline = "2px solid hsl(var(--primary))";
containerStyle.outlineOffset = "2px";
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
/* eslint-disable @typescript-eslint/no-unused-vars */
const {
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
componentConfig: _4, component: _5, isSelected: _6,
onClick: _7, onDragStart: _8, onDragEnd: _9,
size: _10, position: _11, style: _12,
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
web_type: _17, autoGeneration: _18, isInteractive: _19,
formData: _20, onFormDataChange: _21,
menuId: _22, menuObjid: _23, onSave: _24,
userId: _25, userName: _26, companyCode: _27,
isInModal: _28, readonly: _29, originalData: _30,
_originalData: _31, _initialData: _32, _groupedData: _33,
allComponents: _34, onUpdateLayout: _35,
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44,
onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
isPreview: _49, groupedData: _50,
containerType: _51, title: _52, tabs: _53, defaultTab: _54,
sectionVariant: _55, collapsible: _56, defaultCollapsed: _57,
multiple: _58, minRows: _59, maxRows: _60, addRowText: _61,
conditionField: _62, conditionOperator: _63, conditionValue: _64,
padding: _65, transparent: _66, disabled: _67, required: _68,
...domProps
} = props as any;
/* eslint-enable @typescript-eslint/no-unused-vars */
// ─── tabs ──────────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState(
componentConfig.defaultTab ?? componentConfig.tabs?.[0]?.id ?? "tab1",
);
const renderTabs = () => {
const tabs: ContainerTab[] = componentConfig.tabs ?? [
{ id: "tab1", label: "탭 1" },
{ id: "tab2", label: "탭 2" },
{ id: "tab3", label: "탭 3" },
];
// 디자인 모드에서 setActiveTab 도 허용 (사용자가 탭별 미리보기 전환).
const handleSelect = (id: string) => setActiveTab(id);
const currentTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
const activeTabId = currentTab?.id ?? "";
return (
<>
<div
style={{
display: "flex",
gap: "0",
borderBottom: "1px solid hsl(var(--border))",
background: "hsl(var(--muted))",
flexShrink: 0,
}}
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={(e) => {
e.stopPropagation();
handleSelect(tab.id);
}}
style={{
padding: "8px 16px",
fontSize: "12px",
fontWeight: activeTabId === tab.id ? 700 : 500,
color:
activeTabId === tab.id
? "hsl(var(--primary))"
: "hsl(var(--muted-foreground))",
background: activeTabId === tab.id ? "hsl(var(--card))" : "transparent",
border: "none",
borderBottom:
activeTabId === tab.id
? "2px solid hsl(var(--primary))"
: "2px solid transparent",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
{tab.icon && <span aria-hidden>{tab.icon}</span>}
{tab.label}
</button>
))}
</div>
{/*
탭 body — 디자인 모드 빌더에서 drop / 선택을 받는 영역.
data 속성 3개로 ScreenDesigner.handleComponentDrop 의 기존 tabs 분기가 그대로
canonical container 도 인식하게 한다 (별도 코드 패스 추가 없음).
*/}
<div
data-tabs-container="true"
data-container-kind="canonical"
data-component-id={(component as any)?.id ?? ""}
data-active-tab-id={activeTabId}
style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}
onClick={(e) => {
// 빈 탭 안내 영역까지 포함해 body 클릭 시 탭 자식 선택 해제.
// ChildSlot click 은 stopPropagation 하므로 자식 선택과 충돌하지 않는다.
if (!isDesignMode) return;
if (typeof p.onSelectTabComponent === "function") {
e.stopPropagation();
p.onSelectTabComponent(activeTabId, "", null);
}
}}
>
{renderTabChildren(currentTab)}
</div>
</>
);
};
/**
* 활성 탭 한 개의 자식 컴포넌트만 렌더. 비활성 탭은 mount 자체를 하지 않음
* (성능 + side-effect 격리). 자식이 비어 있으면 디자인 모드 drop-zone, 운영
* 모드 빈 영역.
*/
const renderTabChildren = (tab: ContainerTab | undefined): React.ReactNode => {
const childList: ContainerChildComponent[] = tab?.components ?? [];
const tabId = tab?.id ?? "";
const containerId = (component as any)?.id ?? "";
const onSelectChild = typeof p.onSelectTabComponent === "function"
? p.onSelectTabComponent
: undefined;
const selectedChildId: string | undefined =
typeof p.selectedTabComponentId === "string" ? p.selectedTabComponentId : undefined;
const onUpdateContainerComponent = typeof p.onUpdateComponent === "function"
? p.onUpdateComponent
: undefined;
if (childList.length === 0) {
if (isDesignMode) {
return (
<div
style={{
border: "1px dashed hsl(var(--border))",
borderRadius: 4,
padding: "24px",
textAlign: "center",
color: "hsl(var(--muted-foreground))",
fontSize: "11px",
minHeight: 80,
}}
>
. .
</div>
);
}
return null;
}
/**
* 탭에서 특정 자식 삭제. ScreenDesigner 의 onUpdateComponent 가 있으면 그쪽으로
* 상위 layout 업데이트를 위임. 없으면 no-op (운영 모드 등).
*/
const handleRemoveChild = (childId: string) => {
if (!onUpdateContainerComponent) return;
const nextTabs = (componentConfig.tabs ?? []).map((t) =>
t.id === tabId
? { ...t, components: (t.components ?? []).filter((c) => c.id !== childId) }
: t,
);
onUpdateContainerComponent({
...(component as any),
componentConfig: {
...((component as any).componentConfig ?? {}),
tabs: nextTabs,
},
});
// 선택 해제
onSelectChild?.(tabId, "", null);
};
// 운영 모드는 단순 vertical stack. 빌더의 자유 배치 (size/position) 는 G.2
// 범위 밖. 차후 phase 에서 builder layout 모드 추가 시 같은 components 배열
// 위에 좌표 / order 를 얹는 식으로 확장.
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
minHeight: 0,
}}
>
{childList.map((child) => {
const isChildSelected = !!selectedChildId && child.id === selectedChildId;
return (
<ChildSlot
key={child.id}
child={child}
isDesignMode={isDesignMode}
isSelected={isChildSelected}
containerId={containerId}
tabId={tabId}
onSelect={
onSelectChild
? (e) => {
e.stopPropagation();
onSelectChild(tabId, child.id, child);
}
: undefined
}
onRemove={onUpdateContainerComponent ? () => handleRemoveChild(child.id) : undefined}
passProps={{
formData: p.formData,
onFormDataChange: p.onFormDataChange,
userId: p.userId,
userName: p.userName,
companyCode: p.companyCode,
screenId: p.screenId,
menuId: p.menuId,
menuObjid: p.menuObjid,
tableName: p.tableName,
selectedRows: p.selectedRows,
selectedRowsData: p.selectedRowsData,
onSelectedRowsChange: p.onSelectedRowsChange,
refreshKey: p.refreshKey,
onRefresh: p.onRefresh,
isInModal: p.isInModal,
parentTabId: tabId,
parentTabsComponentId: containerId,
}}
/>
);
})}
</div>
);
};
// ─── section ───────────────────────────────────────────────────────────
const [collapsed, setCollapsed] = useState(componentConfig.defaultCollapsed ?? false);
const renderSection = () => {
const variant = componentConfig.sectionVariant ?? "card";
const collapsible = componentConfig.collapsible ?? false;
return (
<>
{title && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))",
background: variant === "paper" ? "hsl(var(--muted))" : "transparent",
}} onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}>
<span style={{ fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
{collapsible && <span style={{ marginRight: "6px" }}>{collapsed ? "▶" : "▼"}</span>}
{title}
</span>
</div>
)}
{!collapsed && (
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
<div style={{ color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center", padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px" }}>
</div>
</div>
)}
</>
);
};
// ─── accordion ─────────────────────────────────────────────────────────
const renderAccordion = () => (
<>
{["항목 1", "항목 2", "항목 3"].map((label, i) => (
<details key={i} open={i === 0} style={{ borderBottom: "1px solid hsl(var(--border))" }}>
<summary style={{
padding: "8px 12px", cursor: "pointer", fontSize: "12px", fontWeight: 600,
color: "hsl(var(--foreground))", background: "hsl(var(--muted))",
listStyle: "none", display: "flex", alignItems: "center", gap: "6px",
}}>
<span style={{ fontSize: "10px" }}></span> {label}
</summary>
<div style={{ padding, fontSize: "11px", color: "hsl(var(--muted-foreground))" }}>
{i + 1}
</div>
</details>
))}
</>
);
// ─── repeater ──────────────────────────────────────────────────────────
const renderRepeater = () => {
const addText = componentConfig.addRowText ?? "+ 행 추가";
return (
<>
{title && (
<div style={{ padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))", fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
{title}
</div>
)}
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
{[0, 1].map((i) => (
<div key={i} style={{
border: "1px dashed hsl(var(--border))", borderRadius: "4px",
padding: "10px", marginBottom: "8px", fontSize: "11px", color: "hsl(var(--muted-foreground))",
}}>
#{i + 1}
</div>
))}
<button type="button" disabled={isDesignMode} style={{
width: "100%", padding: "6px", border: "1px dashed hsl(var(--border))",
borderRadius: "4px", background: "transparent", color: "hsl(var(--muted-foreground))",
fontSize: "11px", cursor: isDesignMode ? "default" : "pointer",
}}>
{addText}
</button>
</div>
</>
);
};
// ─── conditional ───────────────────────────────────────────────────────
const renderConditional = () => (
<>
<div style={{
padding: "6px 12px", background: "hsl(var(--accent))",
borderBottom: "1px solid hsl(var(--border))", fontSize: "11px",
color: "hsl(var(--primary))", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px",
}}>
{componentConfig.conditionField && (
<span style={{ fontWeight: 400, color: "hsl(var(--muted-foreground))" }}>
({componentConfig.conditionField} {componentConfig.conditionOperator ?? "="} {componentConfig.conditionValue ?? "?"})
</span>
)}
</div>
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
<div style={{
color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center",
padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px",
}}>
</div>
</div>
</>
);
const renderBody = () => {
switch (containerType) {
case "tabs": return renderTabs();
case "accordion": return renderAccordion();
case "repeater": return renderRepeater();
case "conditional": return renderConditional();
case "section":
default: return renderSection();
}
};
return (
<div style={containerStyle} className={className}
onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} {...filterDOMProps(domProps)}>
{renderBody()}
</div>
);
};
/**
* ChildSlot — 탭 내부 단일 자식을 `DynamicComponentRenderer` 로 렌더하는 어댑터.
*
* `ContainerChildComponent` 의 `componentType / componentConfig / size` 를 renderer
* 가 기대하는 shape (`component: { componentType, componentConfig }`) 으로 정규화.
* 디자인 모드에서는 클릭 선택 / hover 삭제 버튼 / 선택 outline 을 제공.
*/
const ChildSlot: React.FC<{
child: ContainerChildComponent;
isDesignMode: boolean;
isSelected?: boolean;
containerId?: string;
tabId?: string;
onSelect?: (e: React.MouseEvent) => void;
onRemove?: () => void;
passProps: Record<string, any>;
}> = ({ child, isDesignMode, isSelected, containerId, tabId, onSelect, onRemove, passProps }) => {
const [hover, setHover] = React.useState(false);
const componentForRenderer = React.useMemo(
() => ({
id: child.id,
componentType: child.componentType,
type: child.componentType,
componentConfig: child.componentConfig ?? {},
// legacy snake_case 호환 — DynamicComponentRenderer 가 둘 다 읽음
component_type: child.componentType,
component_config: child.componentConfig ?? {},
// size 가 있으면 inline style 으로 박스 폭만 통제 (기본은 부모 폭 100%)
size: child.size,
}),
[child.id, child.componentType, child.componentConfig, child.size],
);
const wrapStyle: React.CSSProperties = {
width: child.size?.width ?? "100%",
height: child.size?.height,
minHeight: 0,
flexShrink: 0,
position: "relative",
cursor: isDesignMode && onSelect ? "pointer" : undefined,
outline:
isDesignMode && isSelected
? "2px solid hsl(var(--primary))"
: isDesignMode && hover
? "1px dashed hsl(var(--primary))"
: "none",
outlineOffset: 2,
borderRadius: 4,
};
return (
<div
style={wrapStyle}
data-tab-child-id={child.id}
data-parent-tab-id={tabId}
data-parent-tabs-id={containerId}
onClick={isDesignMode && onSelect ? onSelect : undefined}
onMouseEnter={isDesignMode ? () => setHover(true) : undefined}
onMouseLeave={isDesignMode ? () => setHover(false) : undefined}
>
{isDesignMode && (isSelected || hover) && onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
title="이 탭 자식 삭제"
style={{
position: "absolute",
top: 4,
right: 4,
zIndex: 5,
width: 22,
height: 22,
padding: 0,
border: "1px solid hsl(var(--border))",
borderRadius: 4,
background: "hsl(var(--card))",
color: "hsl(var(--destructive))",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
lineHeight: 1,
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
}}
>
</button>
)}
{/*
디자인 모드에서는 자식 컴포넌트의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을
가로채지 않도록 pointer-events 차단. 운영 모드에서는 자식이 정상 동작.
*/}
<div
style={{
pointerEvents: isDesignMode ? "none" : undefined,
width: "100%",
height: "100%",
}}
>
<DynamicComponentRenderer
component={componentForRenderer as any}
isDesignMode={isDesignMode}
{...passProps}
/>
</div>
</div>
);
};
export const ContainerWrapper: React.FC<ContainerComponentProps> = (props) => {
return <ContainerComponent {...props} />;
};