feat(pop-search): 모달 뷰 전면 개선 - 아이콘 뷰, 가나다/ABC 필터 탭, 컬럼 라벨

모달 타입 통합 (modal-table/card/icon-grid -> modal 1종):
- normalizeInputType()으로 레거시 저장값 호환
- 캔버스 모달 모드 완전 제거 (ModalMode, modalCanvasId, returnEvent)
- SearchInputType 9종으로 정리

모달 뷰 실제 구현:
- TableView / IconView 분리 렌더링 (displayStyle 반영)
- 아이콘 뷰: 이름 첫 글자 컬러 카드 + 초성 그룹 헤더
- getIconColor() 결정적 해시 색상 (16색 팔레트)

가나다/ABC 필터 탭:
- ModalFilterTab 타입 + getGroupKey() 한글 초성 추출
- 쌍자음 합침 (ㄲ->ㄱ, ㄸ->ㄷ 등)
- 모달 상단 토글 버튼으로 초성/알파벳 섹션 그룹화

디자이너 설정 개선:
- 컬럼 헤더 라벨 커스터마이징 (columnLabels)
- 필터 탭 활성화 체크박스 (가나다/ABC)
- card 스타일 제거, 정렬 옵션 제거
- 검색 방식 (포함/시작/같음) 유지

시나리오 A 모달 선택 필터링:
- ConnectionEditor 필터 컬럼에 DB 전체 컬럼 표시
- pop-string-list 복수 필터 AND 지원
- useConnectionResolver 페이로드 구조 정규화

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim
2026-02-24 12:52:29 +09:00
parent 9ccd94d927
commit 1acd9fc3b2
10 changed files with 1110 additions and 356 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@@ -21,6 +21,7 @@ import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
// ========================================
// Props
@@ -101,10 +102,11 @@ export default function ConnectionEditor({
}
// ========================================
// 대상 컴포넌트의 컬럼 목록 추출
// 대상 컴포넌트에서 정보 추출
// ========================================
function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
/** 화면에 표시 중인 컬럼만 추출 */
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
if (!comp?.config) return [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
@@ -124,6 +126,14 @@ function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): strin
return cols;
}
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
if (!comp?.config) return "";
const cfg = comp.config as Record<string, unknown>;
const ds = cfg.dataSource as { tableName?: string } | undefined;
return ds?.tableName || "";
}
// ========================================
// 보내기 섹션
// ========================================
@@ -262,11 +272,47 @@ function ConnectionForm({
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
: null;
const targetColumns = React.useMemo(
() => extractTargetColumns(targetComp || undefined),
// 화면에 표시 중인 컬럼
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
[targetComp]
);
// DB 테이블 전체 컬럼 (비동기 조회)
const tableName = React.useMemo(
() => extractTableName(targetComp || undefined),
[targetComp]
);
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
React.useEffect(() => {
if (!tableName) {
setAllDbColumns([]);
return;
}
let cancelled = false;
setDbColumnsLoading(true);
getTableColumns(tableName).then((res) => {
if (cancelled) return;
if (res.success && res.data?.columns) {
setAllDbColumns(res.data.columns.map((c) => c.columnName));
} else {
setAllDbColumns([]);
}
setDbColumnsLoading(false);
});
return () => { cancelled = true; };
}, [tableName]);
// 표시 컬럼과 데이터 전용 컬럼 분리
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
const dataOnlyColumns = React.useMemo(
() => allDbColumns.filter((c) => !displaySet.has(c)),
[allDbColumns, displaySet]
);
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
const toggleColumn = (col: string) => {
setFilterColumns((prev) =>
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
@@ -384,25 +430,61 @@ function ConnectionForm({
{/* 필터 설정 */}
{selectedTargetInput && (
<div className="space-y-2 rounded bg-gray-50 p-2">
{/* 컬럼 선택 (복수) */}
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{targetColumns.length > 0 ? (
<div className="space-y-1.5">
{targetColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
{dbColumnsLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : hasAnyColumns ? (
<div className="space-y-2">
{/* 표시 컬럼 그룹 */}
{displayColumns.length > 0 && (
<div className="space-y-1">
<p className="text-[9px] font-medium text-green-600"> </p>
{displayColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs"
>
{col}
</label>
</div>
))}
</div>
))}
)}
{/* 데이터 전용 컬럼 그룹 */}
{dataOnlyColumns.length > 0 && (
<div className="space-y-1">
{displayColumns.length > 0 && (
<div className="my-1 h-px bg-gray-200" />
)}
<p className="text-[9px] font-medium text-amber-600"> </p>
{dataOnlyColumns.map((col) => (
<div key={col} className="flex items-center gap-2">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
checked={filterColumns.includes(col)}
onCheckedChange={() => toggleColumn(col)}
/>
<label
htmlFor={`col-${col}-${initial?.id || "new"}`}
className="cursor-pointer text-xs text-muted-foreground"
>
{col}
</label>
</div>
))}
</div>
)}
</div>
) : (
<Input