feat: TableSearchWidget 높이 자동 조정 및 컴포넌트 재배치 기능 구현

- TableSearchWidgetHeightContext 추가: 위젯 높이 변화 관리
- TableSearchWidget: ResizeObserver로 높이 변화 감지 및 localStorage 저장
- 실제 화면(/screens/[screenId]/page.tsx)에서만 동작 (디자이너 제외)
- TableSearchWidget 아래 컴포넌트들의 Y 위치를 높이 차이만큼 자동 조정
- 화면 로딩 시 저장된 높이 복원 및 컴포넌트 위치 재조정

주요 변경사항:
1. 필터가 여러 줄로 wrap될 때 높이 자동 증가
2. 높이가 늘어난 만큼 아래 컴포넌트들이 자동으로 아래로 이동
3. 새로고침 후에도 설정 유지 (localStorage)
4. 화면 디자이너에서는 기존대로 동작 (영향 없음)

기술 구현:
- Context API로 위젯 높이 전역 관리
- ResizeObserver로 실시간 높이 감지
- localStorage로 사용자별 높이 설정 영구 저장
- 컴포넌트 렌더링 시 동적 Y 위치 계산
This commit is contained in:
kjs 2025-11-12 14:54:49 +09:00
parent 6d1743c524
commit a883187889
3 changed files with 245 additions and 6 deletions

View File

@ -1,11 +1,11 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen";
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
@ -19,8 +19,9 @@ import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
export default function ScreenViewPage() {
function ScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
@ -35,6 +36,9 @@ export default function ScreenViewPage() {
// 🆕 모바일 환경 감지
const { isMobile } = useResponsive();
// 🆕 TableSearchWidget 높이 관리
const { getHeightDiff } = useTableSearchWidgetHeight();
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true);
@ -393,10 +397,55 @@ export default function ScreenViewPage() {
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
if (isTableSearchWidget) {
// TableSearchWidget 자체는 조정하지 않음
return component;
}
// TableSearchWidget들을 찾아서 해당 위젯 아래에 있는지 확인
const tableSearchWidgets = regularComponents.filter(
(c) => (c as any).componentId === "table-search-widget"
);
let totalHeightAdjustment = 0;
for (const widget of tableSearchWidgets) {
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
if (component.position.y > widget.position.y) {
const heightDiff = getHeightDiff(screenId, widget.id);
if (heightDiff > 0) {
totalHeightAdjustment += heightDiff;
console.log("📏 [ScreenView] 컴포넌트 위치 조정:", {
componentId: component.id,
originalY: component.position.y,
adjustment: heightDiff,
newY: component.position.y + heightDiff,
});
}
}
}
if (totalHeightAdjustment > 0) {
return {
...component,
position: {
...component.position,
y: component.position.y + totalHeightAdjustment,
},
};
}
return component;
});
return (
<>
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => {
{adjustedComponents.map((component) => {
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
return (
<RealtimePreview
@ -712,3 +761,14 @@ export default function ScreenViewPage() {
</ScreenPreviewProvider>
);
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenViewPage />
</TableSearchWidgetHeightProvider>
);
}
export default ScreenViewPageWrapper;

View File

@ -0,0 +1,94 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from "react";
interface WidgetHeight {
screenId: number;
componentId: string;
height: number;
originalHeight: number; // 디자이너에서 설정한 원래 높이
}
interface TableSearchWidgetHeightContextValue {
widgetHeights: Map<string, WidgetHeight>;
setWidgetHeight: (screenId: number, componentId: string, height: number, originalHeight: number) => void;
getWidgetHeight: (screenId: number, componentId: string) => WidgetHeight | undefined;
getHeightDiff: (screenId: number, componentId: string) => number; // 실제 높이 - 원래 높이
}
const TableSearchWidgetHeightContext = createContext<TableSearchWidgetHeightContextValue | undefined>(
undefined
);
export function TableSearchWidgetHeightProvider({ children }: { children: React.ReactNode }) {
const [widgetHeights, setWidgetHeights] = useState<Map<string, WidgetHeight>>(new Map());
const setWidgetHeight = useCallback(
(screenId: number, componentId: string, height: number, originalHeight: number) => {
const key = `${screenId}_${componentId}`;
setWidgetHeights((prev) => {
const newMap = new Map(prev);
newMap.set(key, {
screenId,
componentId,
height,
originalHeight,
});
console.log("📏 [TableSearchWidgetHeightContext] 높이 저장:", {
key,
height,
originalHeight,
heightDiff: height - originalHeight,
});
return newMap;
});
},
[]
);
const getWidgetHeight = useCallback(
(screenId: number, componentId: string): WidgetHeight | undefined => {
const key = `${screenId}_${componentId}`;
return widgetHeights.get(key);
},
[widgetHeights]
);
const getHeightDiff = useCallback(
(screenId: number, componentId: string): number => {
const widgetHeight = getWidgetHeight(screenId, componentId);
if (!widgetHeight) return 0;
const diff = widgetHeight.height - widgetHeight.originalHeight;
return diff;
},
[getWidgetHeight]
);
return (
<TableSearchWidgetHeightContext.Provider
value={{
widgetHeights,
setWidgetHeight,
getWidgetHeight,
getHeightDiff,
}}
>
{children}
</TableSearchWidgetHeightContext.Provider>
);
}
export function useTableSearchWidgetHeight() {
const context = useContext(TableSearchWidgetHeightContext);
if (!context) {
throw new Error(
"useTableSearchWidgetHeight must be used within TableSearchWidgetHeightProvider"
);
}
return context;
}

View File

@ -1,10 +1,11 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
@ -32,10 +33,23 @@ interface TableSearchWidgetProps {
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
};
};
screenId?: number; // 화면 ID
onHeightChange?: (height: number) => void; // 높이 변화 콜백
}
export function TableSearchWidget({ component }: TableSearchWidgetProps) {
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight: ((screenId: number, componentId: string, height: number, originalHeight: number) => void) | undefined;
try {
const heightContext = useTableSearchWidgetHeight();
setWidgetHeight = heightContext.setWidgetHeight;
} catch (e) {
// Context가 없으면 (디자이너 모드) 무시
setWidgetHeight = undefined;
}
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false);
@ -47,6 +61,9 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
@ -164,6 +181,73 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
}
}, [selectedTableId, currentTable?.dataCount]);
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {
if (!containerRef.current || !screenId || !setWidgetHeight) return;
// 컴포넌트의 원래 높이 (디자이너에서 설정한 높이)
const originalHeight = (component as any).size?.height || 50;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
console.log("📏 [TableSearchWidget] 높이 변화 감지:", {
screenId,
componentId: component.id,
originalHeight,
newHeight,
heightDiff: newHeight - originalHeight,
});
// Context에 높이 저장 (다른 컴포넌트 위치 조정에 사용)
setWidgetHeight(screenId, component.id, newHeight, originalHeight);
// localStorage에 높이 저장 (새로고침 시 복원용)
localStorage.setItem(
`table_search_widget_height_screen_${screenId}_${component.id}`,
JSON.stringify({ height: newHeight, originalHeight })
);
// 콜백이 있으면 호출
if (onHeightChange) {
onHeightChange(newHeight);
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [screenId, component.id, setWidgetHeight, onHeightChange]);
// 화면 로딩 시 저장된 높이 복원
useEffect(() => {
if (!screenId || !setWidgetHeight) return;
const storageKey = `table_search_widget_height_screen_${screenId}_${component.id}`;
const savedData = localStorage.getItem(storageKey);
if (savedData) {
try {
const { height, originalHeight } = JSON.parse(savedData);
setWidgetHeight(screenId, component.id, height, originalHeight);
console.log("📥 [TableSearchWidget] 저장된 높이 복원:", {
screenId,
componentId: component.id,
height,
originalHeight,
heightDiff: height - originalHeight,
});
} catch (error) {
console.error("저장된 높이 복원 실패:", error);
}
}
}, [screenId, component.id, setWidgetHeight]);
const hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러
@ -326,6 +410,7 @@ export function TableSearchWidget({ component }: TableSearchWidgetProps) {
return (
<div
ref={containerRef}
className="flex w-full flex-wrap items-center gap-2 border-b bg-card"
style={{
padding: component.style?.padding || "0.75rem",