feat(pop-string-list): 컬럼 관리 개선 및 런타임 컬럼 전환 구현

[Config 패널]
- STEP 6 리스트 레이아웃에 컬럼 추가/삭제 기능 추가
- 메인 + 조인 컬럼을 전환 후보로 확장 (기존: 조인만)
- 독립 헤더로 추가된 컬럼은 전환 후보에서 자동 제외
- STEP 3 체크 변경 시 STEP 6 순서/조인컬럼/alternateColumns 보존
- STEP 4 조인 삭제 시 listColumns/alternateColumns에서 고아 참조 자동 정리

[런타임 컴포넌트]
- 리스트 헤더에 alternateColumns 전환 UI 추가 (Popover 드롭다운)
- 조인 컬럼명 resolveColumnName 유틸 추가 ("테이블.컬럼" -> "컬럼")
- 카드 모드 텍스트 잘림 수정 (gridTemplateRows: minmax 적용)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-13 11:27:40 +09:00
parent 6842a00890
commit 51e1392640
2 changed files with 319 additions and 69 deletions

View File

@ -9,8 +9,14 @@
*/ */
import { useState, useEffect, useCallback } from "react"; 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 { 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 { dataApi } from "@/lib/api/data";
import type { import type {
PopStringListConfig, PopStringListConfig,
@ -19,6 +25,19 @@ import type {
CardCellDefinition, CardCellDefinition,
} from "./types"; } from "./types";
// ===== 유틸리티 =====
/**
*
* "테이블명.컬럼명" -> "컬럼명"
*
*/
function resolveColumnName(name: string): string {
if (!name) return name;
const dotIdx = name.lastIndexOf(".");
return dotIdx >= 0 ? name.substring(dotIdx + 1) : name;
}
// ===== Props ===== // ===== Props =====
interface PopStringListComponentProps { interface PopStringListComponentProps {
@ -219,6 +238,10 @@ interface ListModeViewProps {
} }
function ListModeView({ columns, data }: ListModeViewProps) { function ListModeView({ columns, data }: ListModeViewProps) {
// 런타임 컬럼 전환 상태
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
if (columns.length === 0) { if (columns.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center p-2"> <div className="flex h-full items-center justify-center p-2">
@ -238,15 +261,88 @@ function ListModeView({ columns, data }: ListModeViewProps) {
className="border-b bg-muted/50" className="border-b bg-muted/50"
style={{ display: "grid", gridTemplateColumns: gridCols }} style={{ display: "grid", gridTemplateColumns: gridCols }}
> >
{columns.map((col) => ( {columns.map((col, colIdx) => {
<div const hasAlternates = (col.alternateColumns || []).length > 0;
key={col.columnName} const currentColName = activeColumns[colIdx] || col.columnName;
className="px-2 py-1.5 text-xs font-medium text-muted-foreground" // 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시
style={{ textAlign: col.align || "left" }} const currentLabel =
> currentColName === col.columnName
{col.label} ? col.label
</div> : resolveColumnName(currentColName);
))}
if (hasAlternates) {
// 전환 가능한 헤더: Popover 드롭다운
return (
<Popover key={col.columnName}>
<PopoverTrigger asChild>
<button
className="flex w-full items-center justify-between px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 transition-colors"
style={{ textAlign: col.align || "left" }}
>
<span className="truncate">{currentLabel}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto min-w-[120px] p-1" align="start">
<div className="flex flex-col">
{/* 원래 컬럼 */}
<button
className={cn(
"rounded px-2 py-1 text-left text-xs transition-colors",
currentColName === col.columnName
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted"
)}
onClick={() => {
setActiveColumns((prev) => {
const next = { ...prev };
delete next[colIdx];
return next;
});
}}
>
{col.label} ()
</button>
{/* 대체 컬럼들 */}
{(col.alternateColumns || []).map((altCol) => {
const altLabel = resolveColumnName(altCol);
return (
<button
key={altCol}
className={cn(
"rounded px-2 py-1 text-left text-xs transition-colors",
currentColName === altCol
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted"
)}
onClick={() => {
setActiveColumns((prev) => ({
...prev,
[colIdx]: altCol,
}));
}}
>
{altLabel}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
// 전환 없는 일반 헤더
return (
<div
key={col.columnName}
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
style={{ textAlign: col.align || "left" }}
>
{col.label}
</div>
);
})}
</div> </div>
{/* 데이터 행 */} {/* 데이터 행 */}
@ -256,15 +352,19 @@ function ListModeView({ columns, data }: ListModeViewProps) {
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors" className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
style={{ display: "grid", gridTemplateColumns: gridCols }} style={{ display: "grid", gridTemplateColumns: gridCols }}
> >
{columns.map((col) => ( {columns.map((col, colIdx) => {
<div const currentColName = activeColumns[colIdx] || col.columnName;
key={col.columnName} const resolvedKey = resolveColumnName(currentColName);
className="px-2 py-1.5 text-xs truncate" return (
style={{ textAlign: col.align || "left" }} <div
> key={`${col.columnName}-${colIdx}`}
{String(row[col.columnName] ?? "")} className="px-2 py-1.5 text-xs truncate"
</div> style={{ textAlign: col.align || "left" }}
))} >
{String(row[resolvedKey] ?? "")}
</div>
);
})}
</div> </div>
))} ))}
</div> </div>
@ -305,14 +405,17 @@ function CardModeView({ cardGrid, data }: CardModeViewProps) {
cardGrid.rowHeights && cardGrid.rowHeights.length > 0 cardGrid.rowHeights && cardGrid.rowHeights.length > 0
? cardGrid.rowHeights ? cardGrid.rowHeights
.map((h) => { .map((h) => {
if (!h) return "32px"; if (!h) return "minmax(32px, auto)";
// px 값은 직접 사용, fr 값은 마이그레이션 호환 // px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장
return h.endsWith("px") if (h.endsWith("px")) {
? h return `minmax(${h}, auto)`;
: `${Math.round(parseFloat(h) * 32) || 32}px`; }
// fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용
const px = Math.round(parseFloat(h) * 32) || 32;
return `minmax(${px}px, auto)`;
}) })
.join(" ") .join(" ")
: `repeat(${Number(cardGrid.rows) || 1}, 32px)`, : `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`,
gap: `${Number(cardGrid.gap) || 0}px`, gap: `${Number(cardGrid.gap) || 0}px`,
}} }}
> >
@ -358,7 +461,7 @@ function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactN
<img <img
src={displayValue} src={displayValue}
alt={cell.label || cell.columnName} alt={cell.label || cell.columnName}
className="h-full w-full object-cover rounded" className="h-full max-h-[200px] w-full object-cover rounded"
/> />
) : ( ) : (
<div className="flex h-full items-center justify-center bg-muted rounded"> <div className="flex h-full items-center justify-center bg-muted rounded">

View File

@ -219,13 +219,18 @@ export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps)
onColumnsChange={(cols) => { onColumnsChange={(cols) => {
setSelectedColumns(cols); setSelectedColumns(cols);
if (cfg.displayMode === "list") { if (cfg.displayMode === "list") {
const listColumns: ListColumnConfig[] = cols.map((colName) => { const currentList = cfg.listColumns || [];
const existing = cfg.listColumns?.find( // 기존 리스트에서: 체크 해제된 메인 컬럼만 제거
(lc) => lc.columnName === colName // 조인 컬럼 (이름에 "."이 포함)은 항상 보존
); const preserved = currentList.filter(
return existing || { columnName: colName, label: colName }; (lc) => cols.includes(lc.columnName) || lc.columnName.includes(".")
}); );
update({ selectedColumns: cols, listColumns }); // 새로 체크된 메인 컬럼만 리스트 끝에 추가
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 { } else {
update({ selectedColumns: cols }); update({ selectedColumns: cols });
} }
@ -237,7 +242,34 @@ export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps)
dataSource={cfg.dataSource} dataSource={cfg.dataSource}
tables={tables} tables={tables}
mainColumns={columns} 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 && {step === 5 &&
@ -1077,6 +1109,65 @@ function StepListLayout({
// 컬럼 전환 설정 펼침 인덱스 // 컬럼 전환 설정 펼침 인덱스
const [expandedAltIdx, setExpandedAltIdx] = useState<number | null>(null); const [expandedAltIdx, setExpandedAltIdx] = useState<number | null>(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<ListColumnConfig>) => { const updateColumn = (index: number, partial: Partial<ListColumnConfig>) => {
const next = listColumns.map((col, i) => const next = listColumns.map((col, i) =>
i === index ? { ...col, ...partial } : col i === index ? { ...col, ...partial } : col
@ -1163,7 +1254,7 @@ function StepListLayout({
setDraggableRow(null); setDraggableRow(null);
}; };
if (listColumns.length === 0) { if (listColumns.length === 0 && addableColumns.length === 0) {
return ( return (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -1309,8 +1400,8 @@ function StepListLayout({
</SelectContent> </SelectContent>
</Select> </Select>
{/* 컬럼 전환 버튼 (조인 컬럼 있을 때만) */} {/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */}
{joinedColumns.length > 0 && ( {getAlternateCandidates(col.columnName).length > 0 && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -1327,44 +1418,100 @@ function StepListLayout({
<ChevronsUpDown className="h-3 w-3" /> <ChevronsUpDown className="h-3 w-3" />
</button> </button>
)} )}
{/* 컬럼 삭제 버튼 */}
<button
type="button"
onClick={() => removeColumn(i)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
title="컬럼 삭제"
>
<Trash2 className="h-3 w-3" />
</button>
</div> </div>
{/* 전환 가능 컬럼 (펼침 시만 표시, 조인 컬럼만) */} {/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */}
{expandedAltIdx === i && joinedColumns.length > 0 && ( {expandedAltIdx === i && (() => {
<div className="ml-5 flex items-center gap-1 pb-1"> const candidates = getAlternateCandidates(col.columnName);
<span className="shrink-0 text-[8px] text-muted-foreground">:</span> if (candidates.length === 0) return null;
{joinedColumns.map((jc) => { return (
const alts = col.alternateColumns || []; <div className="ml-5 flex flex-wrap items-center gap-1 pb-1">
const isAlt = alts.includes(jc.name); <span className="shrink-0 text-[8px] text-muted-foreground">:</span>
return ( {candidates.map((cand) => {
<button const alts = col.alternateColumns || [];
key={jc.name} const isAlt = alts.includes(cand.value);
type="button" return (
onClick={() => { <button
const newAlts = isAlt key={cand.value}
? alts.filter((a) => a !== jc.name) type="button"
: [...alts, jc.name]; onClick={() => {
updateColumn(i, { const newAlts = isAlt
alternateColumns: newAlts.length > 0 ? newAlts : undefined, ? alts.filter((a) => a !== cand.value)
}); : [...alts, cand.value];
}} updateColumn(i, {
className={cn( alternateColumns: newAlts.length > 0 ? newAlts : undefined,
"rounded border px-1.5 py-0.5 text-[8px] transition-colors", });
isAlt }}
? "border-primary bg-primary/10 text-primary" className={cn(
: "border-border hover:bg-muted" "rounded border px-1.5 py-0.5 text-[8px] transition-colors",
)} isAlt
> ? "border-primary bg-primary/10 text-primary"
{jc.displayName} : "border-border hover:bg-muted"
</button> )}
); >
})} {cand.label}
</div> {cand.source === "join" && (
)} <span className="ml-0.5 text-[7px] text-muted-foreground">*</span>
)}
</button>
);
})}
</div>
);
})()}
</Fragment> </Fragment>
))} ))}
</div> </div>
{/* 컬럼 추가 */}
{addableColumns.length > 0 && (
<Select onValueChange={addColumn}>
<SelectTrigger className="h-7 w-full text-[10px]">
<SelectValue placeholder="+ 컬럼 추가..." />
</SelectTrigger>
<SelectContent>
{addableColumns.some((c) => c.source === "main") && (
<>
<SelectItem value="__main_header__" disabled className="text-[8px] font-medium text-muted-foreground">
</SelectItem>
{addableColumns
.filter((c) => c.source === "main")
.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">
{c.label}
</SelectItem>
))}
</>
)}
{addableColumns.some((c) => c.source === "join") && (
<>
<SelectItem value="__join_header__" disabled className="text-[8px] font-medium text-muted-foreground">
</SelectItem>
{addableColumns
.filter((c) => c.source === "join")
.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-[10px]">
{c.label}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
)}
<p className="text-[8px] text-muted-foreground"> <p className="text-[8px] text-muted-foreground">
| |
</p> </p>