From 51e13926405542a4cce3badbf5c28ff9e04b66ad Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 13 Feb 2026 11:27:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-string-list):=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EC=BB=AC=EB=9F=BC=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Config 패널] - STEP 6 리스트 레이아웃에 컬럼 추가/삭제 기능 추가 - 메인 + 조인 컬럼을 전환 후보로 확장 (기존: 조인만) - 독립 헤더로 추가된 컬럼은 전환 후보에서 자동 제외 - STEP 3 체크 변경 시 STEP 6 순서/조인컬럼/alternateColumns 보존 - STEP 4 조인 삭제 시 listColumns/alternateColumns에서 고아 참조 자동 정리 [런타임 컴포넌트] - 리스트 헤더에 alternateColumns 전환 UI 추가 (Popover 드롭다운) - 조인 컬럼명 resolveColumnName 유틸 추가 ("테이블.컬럼" -> "컬럼") - 카드 모드 텍스트 잘림 수정 (gridTemplateRows: minmax 적용) Co-authored-by: Cursor --- .../PopStringListComponent.tsx | 155 ++++++++++-- .../pop-string-list/PopStringListConfig.tsx | 233 ++++++++++++++---- 2 files changed, 319 insertions(+), 69 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx index 26db0d53..e659b920 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -9,8 +9,14 @@ */ import { useState, useEffect, useCallback } from "react"; -import { ChevronDown, ChevronUp, Loader2, AlertCircle } from "lucide-react"; +import { ChevronDown, ChevronUp, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import { dataApi } from "@/lib/api/data"; import type { PopStringListConfig, @@ -19,6 +25,19 @@ import type { CardCellDefinition, } from "./types"; +// ===== 유틸리티 ===== + +/** + * 컬럼명에서 실제 데이터 키를 추출 + * 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출 + * 일반 컬럼은 그대로 반환 + */ +function resolveColumnName(name: string): string { + if (!name) return name; + const dotIdx = name.lastIndexOf("."); + return dotIdx >= 0 ? name.substring(dotIdx + 1) : name; +} + // ===== Props ===== interface PopStringListComponentProps { @@ -219,6 +238,10 @@ interface ListModeViewProps { } function ListModeView({ columns, data }: ListModeViewProps) { + // 런타임 컬럼 전환 상태 + // key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName) + const [activeColumns, setActiveColumns] = useState>({}); + if (columns.length === 0) { return (
@@ -238,15 +261,88 @@ function ListModeView({ columns, data }: ListModeViewProps) { className="border-b bg-muted/50" style={{ display: "grid", gridTemplateColumns: gridCols }} > - {columns.map((col) => ( -
- {col.label} -
- ))} + {columns.map((col, colIdx) => { + const hasAlternates = (col.alternateColumns || []).length > 0; + const currentColName = activeColumns[colIdx] || col.columnName; + // 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시 + const currentLabel = + currentColName === col.columnName + ? col.label + : resolveColumnName(currentColName); + + if (hasAlternates) { + // 전환 가능한 헤더: Popover 드롭다운 + return ( + + + + + +
+ {/* 원래 컬럼 */} + + {/* 대체 컬럼들 */} + {(col.alternateColumns || []).map((altCol) => { + const altLabel = resolveColumnName(altCol); + return ( + + ); + })} +
+
+
+ ); + } + + // 전환 없는 일반 헤더 + return ( +
+ {col.label} +
+ ); + })}
{/* 데이터 행 */} @@ -256,15 +352,19 @@ function ListModeView({ columns, data }: ListModeViewProps) { className="border-b last:border-b-0 hover:bg-muted/30 transition-colors" style={{ display: "grid", gridTemplateColumns: gridCols }} > - {columns.map((col) => ( -
- {String(row[col.columnName] ?? "")} -
- ))} + {columns.map((col, colIdx) => { + const currentColName = activeColumns[colIdx] || col.columnName; + const resolvedKey = resolveColumnName(currentColName); + return ( +
+ {String(row[resolvedKey] ?? "")} +
+ ); + })} ))} @@ -305,14 +405,17 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) { cardGrid.rowHeights && cardGrid.rowHeights.length > 0 ? cardGrid.rowHeights .map((h) => { - if (!h) return "32px"; - // px 값은 직접 사용, fr 값은 마이그레이션 호환 - return h.endsWith("px") - ? h - : `${Math.round(parseFloat(h) * 32) || 32}px`; + if (!h) return "minmax(32px, auto)"; + // px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장 + if (h.endsWith("px")) { + return `minmax(${h}, auto)`; + } + // fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용 + const px = Math.round(parseFloat(h) * 32) || 32; + return `minmax(${px}px, auto)`; }) .join(" ") - : `repeat(${Number(cardGrid.rows) || 1}, 32px)`, + : `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`, gap: `${Number(cardGrid.gap) || 0}px`, }} > @@ -358,7 +461,7 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN {cell.label ) : (
diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx index 8da80c8d..f4702a16 100644 --- a/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx @@ -219,13 +219,18 @@ export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) onColumnsChange={(cols) => { setSelectedColumns(cols); if (cfg.displayMode === "list") { - const listColumns: ListColumnConfig[] = cols.map((colName) => { - const existing = cfg.listColumns?.find( - (lc) => lc.columnName === colName - ); - return existing || { columnName: colName, label: colName }; - }); - update({ selectedColumns: cols, listColumns }); + const currentList = cfg.listColumns || []; + // 기존 리스트에서: 체크 해제된 메인 컬럼만 제거 + // 조인 컬럼 (이름에 "."이 포함)은 항상 보존 + const preserved = currentList.filter( + (lc) => cols.includes(lc.columnName) || lc.columnName.includes(".") + ); + // 새로 체크된 메인 컬럼만 리스트 끝에 추가 + const existingNames = new Set(preserved.map((lc) => lc.columnName)); + const added = cols + .filter((colName) => !existingNames.has(colName)) + .map((colName) => ({ columnName: colName, label: colName } as ListColumnConfig)); + update({ selectedColumns: cols, listColumns: [...preserved, ...added] }); } else { update({ selectedColumns: cols }); } @@ -237,7 +242,34 @@ export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) dataSource={cfg.dataSource} tables={tables} mainColumns={columns} - onChange={(dataSource) => update({ dataSource })} + onChange={(dataSource) => { + // 조인 변경 후: 유효한 조인 컬럼명 셋 계산 + const validJoinColNames = new Set( + (dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => `${j.targetTable}.${col}`) + ) + ); + // listColumns에서 고아 조인 컬럼 제거 + alternateColumns 정리 + const currentList = cfg.listColumns || []; + const cleanedList = currentList + .filter((lc) => { + if (!lc.columnName.includes(".")) return true; // 메인 컬럼: 유지 + return validJoinColNames.has(lc.columnName); // 조인 컬럼: 유효한 것만 + }) + .map((lc) => { + const alts = lc.alternateColumns; + if (!alts) return lc; + const cleanedAlts = alts.filter((a) => { + if (!a.includes(".")) return true; // 메인 컬럼: 유지 + return validJoinColNames.has(a); // 조인 컬럼: 유효한 것만 + }); + return { + ...lc, + alternateColumns: cleanedAlts.length > 0 ? cleanedAlts : undefined, + }; + }); + update({ dataSource, listColumns: cleanedList }); + }} /> )} {step === 5 && @@ -1077,6 +1109,65 @@ function StepListLayout({ // 컬럼 전환 설정 펼침 인덱스 const [expandedAltIdx, setExpandedAltIdx] = useState(null); + // 리스트에 현재 포함된 컬럼명 셋 + const listColumnNames = new Set(listColumns.map((c) => c.columnName)); + + // 추가 가능한 컬럼: (메인 + 조인) 중 현재 리스트에 없는 것 + const addableColumns = [ + ...availableColumns + .filter((c) => !listColumnNames.has(c.name)) + .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), + ...joinedColumns + .filter((c) => !listColumnNames.has(c.name)) + .map((c) => ({ + value: c.name, + label: `${c.displayName} (${c.sourceTable})`, + source: "join" as const, + })), + ]; + + // 컬럼 추가 (독립 헤더로 추가 시 다른 컬럼의 alternateColumns에서 제거) + const addColumn = (columnValue: string) => { + const joinCol = joinedColumns.find((c) => c.name === columnValue); + const newCol: ListColumnConfig = { + columnName: columnValue, + label: joinCol?.displayName || columnValue, + }; + // 다른 컬럼의 alternateColumns에서 이 컬럼 제거 (독립 헤더가 되므로) + const cleaned = listColumns.map((col) => { + const alts = col.alternateColumns; + if (!alts || !alts.includes(columnValue)) return col; + const newAlts = alts.filter((a) => a !== columnValue); + return { ...col, alternateColumns: newAlts.length > 0 ? newAlts : undefined }; + }); + onChange([...cleaned, newCol]); + }; + + // 컬럼 삭제 (리스트에서만 삭제, STEP 3 체크 유지) + const removeColumn = (index: number) => { + const next = listColumns.filter((_, i) => i !== index); + onChange(next); + // 펼침 인덱스 초기화 (삭제로 인덱스가 밀리므로) + setExpandedAltIdx(null); + }; + + // 전환 후보: (메인 + 조인) - 자기 자신 - 리스트에 독립 헤더로 있는 것 + const getAlternateCandidates = (currentColumnName: string) => { + return [ + ...availableColumns + .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) + .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), + ...joinedColumns + .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) + .map((c) => ({ + value: c.name, + label: c.displayName, + source: "join" as const, + sourceTable: c.sourceTable, + })), + ]; + }; + const updateColumn = (index: number, partial: Partial) => { const next = listColumns.map((col, i) => i === index ? { ...col, ...partial } : col @@ -1163,7 +1254,7 @@ function StepListLayout({ setDraggableRow(null); }; - if (listColumns.length === 0) { + if (listColumns.length === 0 && addableColumns.length === 0) { return (

컬럼을 먼저 선택하세요 @@ -1309,8 +1400,8 @@ function StepListLayout({ - {/* 컬럼 전환 버튼 (조인 컬럼 있을 때만) */} - {joinedColumns.length > 0 && ( + {/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */} + {getAlternateCandidates(col.columnName).length > 0 && ( )} + + {/* 컬럼 삭제 버튼 */} +

- {/* 전환 가능 컬럼 (펼침 시만 표시, 조인 컬럼만) */} - {expandedAltIdx === i && joinedColumns.length > 0 && ( -
- 전환: - {joinedColumns.map((jc) => { - const alts = col.alternateColumns || []; - const isAlt = alts.includes(jc.name); - return ( - - ); - })} -
- )} + {/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */} + {expandedAltIdx === i && (() => { + const candidates = getAlternateCandidates(col.columnName); + if (candidates.length === 0) return null; + return ( +
+ 전환: + {candidates.map((cand) => { + const alts = col.alternateColumns || []; + const isAlt = alts.includes(cand.value); + return ( + + ); + })} +
+ ); + })()} ))} + {/* 컬럼 추가 */} + {addableColumns.length > 0 && ( + + )} +

행을 드래그하여 순서 변경 | 상단 바 경계를 드래그하여 너비 조정