feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현

테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현

- 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬)
- 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정)
- 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정
- 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택
- CardColumnJoin에 selectedTargetColumns 필드 추가
- 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-13 09:03:52 +09:00
parent 84426b82cf
commit 6842a00890
11 changed files with 3271 additions and 2 deletions

View File

@ -56,8 +56,10 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-field": "필드",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
"pop-list": "리스트",
"pop-indicator": "인디케이터",
"pop-scanner": "스캐너",

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -51,6 +51,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: MousePointerClick,
description: "액션 버튼 (저장/삭제/API/모달)",
},
{
type: "pop-string-list",
label: "리스트 목록",
icon: List,
description: "테이블 데이터를 리스트/카드로 표시",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -69,6 +69,9 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-card-list": "카드 목록",
"pop-button": "버튼",
"pop-string-list": "리스트 목록",
};
// ========================================

View File

@ -9,7 +9,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list";
/**
*
@ -346,6 +346,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
"pop-card-list": { colSpan: 4, rowSpan: 3 },
"pop-button": { colSpan: 2, rowSpan: 1 },
"pop-string-list": { colSpan: 4, rowSpan: 3 },
};
/**

View File

@ -18,6 +18,7 @@ import "./pop-dashboard";
import "./pop-card-list";
import "./pop-button";
import "./pop-string-list";
// 향후 추가될 컴포넌트들:
// import "./pop-field";

View File

@ -0,0 +1,406 @@
"use client";
/**
* pop-string-list
*
* 모드: 엑셀형 / (CSS Grid)
* 모드: (CSS Grid + colSpan/rowSpan)
* 오버플로우: visibleRows + "전체보기"
*/
import { useState, useEffect, useCallback } from "react";
import { ChevronDown, ChevronUp, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dataApi } from "@/lib/api/data";
import type {
PopStringListConfig,
CardGridConfig,
ListColumnConfig,
CardCellDefinition,
} from "./types";
// ===== Props =====
interface PopStringListComponentProps {
config?: PopStringListConfig;
className?: string;
}
// 테이블 행 데이터 타입
type RowData = Record<string, unknown>;
// ===== 메인 컴포넌트 =====
export function PopStringListComponent({
config,
className,
}: PopStringListComponentProps) {
const displayMode = config?.displayMode || "list";
const header = config?.header;
const overflow = config?.overflow;
const dataSource = config?.dataSource;
const listColumns = config?.listColumns || [];
const cardGrid = config?.cardGrid;
// 데이터 상태
const [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
// 오버플로우 계산 (JSON 복원 시 string 유입 방어)
const visibleRows = Number(overflow?.visibleRows) || 5;
const maxExpandRows = Number(overflow?.maxExpandRows) || 20;
const showExpandButton = overflow?.showExpandButton ?? true;
// 표시할 데이터 슬라이스
const visibleData = expanded
? rows.slice(0, maxExpandRows)
: rows.slice(0, visibleRows);
const hasMore = rows.length > visibleRows;
// 확장/축소 토글
const toggleExpanded = useCallback(() => {
setExpanded((prev) => !prev);
}, []);
// 데이터 조회
useEffect(() => {
if (!dataSource?.tableName) {
setLoading(false);
setRows([]);
return;
}
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// 필터 조건 구성
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
if (f.column && f.value) {
filters[f.column] = f.value;
}
});
}
// 정렬 조건
const sortBy = dataSource.sort?.column;
const sortOrder = dataSource.sort?.direction;
// 개수 제한 (string 유입 방어: Number 캐스팅)
const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count
? Number(dataSource.limit.count)
: maxExpandRows;
const result = await dataApi.getTableData(dataSource.tableName, {
page: 1,
size,
sortBy: sortOrder ? sortBy : undefined,
sortOrder,
filters: Object.keys(filters).length > 0 ? filters : undefined,
});
setRows(result.data || []);
} catch (err) {
const message =
err instanceof Error ? err.message : "데이터 조회 실패";
setError(message);
setRows([]);
} finally {
setLoading(false);
}
};
fetchData();
}, [dataSource, maxExpandRows]);
// 로딩 상태
if (loading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-xs text-destructive">{error}</span>
</div>
);
}
// 테이블 미선택
if (!dataSource?.tableName) {
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">
</span>
</div>
);
}
// 데이터 없음
if (rows.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">
</span>
</div>
);
}
return (
<div className={`flex h-full w-full flex-col overflow-hidden ${className || ""}`}>
{/* 헤더 */}
{header?.enabled && header.label && (
<div className="shrink-0 border-b px-3 py-2">
<span className="text-sm font-medium">{header.label}</span>
</div>
)}
{/* 컨텐츠 */}
<div className="flex-1 overflow-auto">
{displayMode === "list" ? (
<ListModeView
columns={listColumns}
data={visibleData}
/>
) : (
<CardModeView
cardGrid={cardGrid}
data={visibleData}
/>
)}
</div>
{/* 전체보기 버튼 */}
{showExpandButton && hasMore && (
<div className="shrink-0 border-t px-3 py-1.5">
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className="h-7 w-full text-xs text-muted-foreground"
>
{expanded ? (
<>
<ChevronUp className="mr-1 h-3 w-3" />
</>
) : (
<>
<ChevronDown className="mr-1 h-3 w-3" />
({rows.length})
</>
)}
</Button>
</div>
)}
</div>
);
}
// ===== 리스트 모드 =====
interface ListModeViewProps {
columns: ListColumnConfig[];
data: RowData[];
}
function ListModeView({ columns, data }: ListModeViewProps) {
if (columns.length === 0) {
return (
<div className="flex h-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">
</span>
</div>
);
}
const gridCols = columns.map((c) => c.width || "1fr").join(" ");
return (
<div className="w-full">
{/* 헤더 행 */}
<div
className="border-b bg-muted/50"
style={{ display: "grid", gridTemplateColumns: gridCols }}
>
{columns.map((col) => (
<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>
{/* 데이터 행 */}
{data.map((row, i) => (
<div
key={i}
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
style={{ display: "grid", gridTemplateColumns: gridCols }}
>
{columns.map((col) => (
<div
key={col.columnName}
className="px-2 py-1.5 text-xs truncate"
style={{ textAlign: col.align || "left" }}
>
{String(row[col.columnName] ?? "")}
</div>
))}
</div>
))}
</div>
);
}
// ===== 카드 모드 =====
interface CardModeViewProps {
cardGrid?: CardGridConfig;
data: RowData[];
}
function CardModeView({ cardGrid, data }: CardModeViewProps) {
if (!cardGrid || (cardGrid.cells || []).length === 0) {
return (
<div className="flex h-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">
</span>
</div>
);
}
return (
<div className="space-y-2 p-2">
{data.map((row, i) => (
<div
key={i}
className="rounded-md border"
style={{
display: "grid",
gridTemplateColumns:
cardGrid.colWidths && cardGrid.colWidths.length > 0
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
: "1fr",
gridTemplateRows:
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`;
})
.join(" ")
: `repeat(${Number(cardGrid.rows) || 1}, 32px)`,
gap: `${Number(cardGrid.gap) || 0}px`,
}}
>
{(cardGrid.cells || []).map((cell) => {
// 가로 정렬 매핑
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
return (
<div
key={cell.id}
className="overflow-hidden p-1.5"
style={{
display: "flex",
flexDirection: "column",
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
alignItems: justifyMap[cell.align || "left"],
gridColumn: `${Number(cell.col) || 1} / span ${Number(cell.colSpan) || 1}`,
gridRow: `${Number(cell.row) || 1} / span ${Number(cell.rowSpan) || 1}`,
border: cardGrid.showBorder
? "1px solid hsl(var(--border))"
: "none",
}}
>
{renderCellContent(cell, row)}
</div>
);
})}
</div>
))}
</div>
);
}
// ===== 셀 컨텐츠 렌더링 =====
function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode {
const value = row[cell.columnName];
const displayValue = value != null ? String(value) : "";
switch (cell.type) {
case "image":
return displayValue ? (
<img
src={displayValue}
alt={cell.label || cell.columnName}
className="h-full w-full object-cover rounded"
/>
) : (
<div className="flex h-full items-center justify-center bg-muted rounded">
<span className="text-[10px] text-muted-foreground">No Image</span>
</div>
);
case "badge":
return (
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{displayValue}
</span>
);
case "button":
return (
<Button variant="outline" size="sm" className="h-6 text-[10px]">
{cell.label || displayValue}
</Button>
);
case "text":
default: {
// 글자 크기 매핑
const fontSizeClass =
cell.fontSize === "sm"
? "text-[10px]"
: cell.fontSize === "lg"
? "text-sm"
: "text-xs"; // md (기본)
const isLabelLeft = cell.labelPosition === "left";
return (
<div className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}>
{cell.label && (
<span className="text-[10px] text-muted-foreground shrink-0">
{cell.label}{isLabelLeft ? ":" : ""}
</span>
)}
<span className={`${fontSizeClass} truncate`}>{displayValue}</span>
</div>
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,176 @@
"use client";
/**
* pop-string-list
*
* .
* .
*/
import type { PopStringListConfig } from "./types";
interface PopStringListPreviewProps {
config?: PopStringListConfig;
}
export function PopStringListPreviewComponent({
config,
}: PopStringListPreviewProps) {
const displayMode = config?.displayMode || "list";
const header = config?.header;
const tableName = config?.dataSource?.tableName;
const listColumns = config?.listColumns || [];
const cardGrid = config?.cardGrid;
// 테이블 미선택
if (!tableName) {
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-[10px] text-muted-foreground">
</span>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 헤더 */}
{header?.enabled && (
<div className="shrink-0 border-b px-2 py-1">
<span className="text-[10px] font-medium">
{header.label || "리스트 목록"}
</span>
</div>
)}
{/* 모드별 미리보기 */}
<div className="flex-1 overflow-hidden p-1">
{displayMode === "list" ? (
<ListPreview columns={listColumns} />
) : (
<CardPreview cardGrid={cardGrid} />
)}
</div>
{/* 모드 라벨 */}
<div className="shrink-0 border-t px-2 py-0.5">
<span className="text-[8px] text-muted-foreground">
{displayMode === "list" ? "리스트" : "카드"} | {tableName}
</span>
</div>
</div>
);
}
// ===== 리스트 미리보기 =====
function ListPreview({
columns,
}: {
columns: PopStringListConfig["listColumns"];
}) {
const cols = columns || [];
if (cols.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<span className="text-[8px] text-muted-foreground"> </span>
</div>
);
}
const gridCols = cols.map((c) => c.width || "1fr").join(" ");
const dummyRows = 3;
return (
<div className="w-full">
{/* 헤더 */}
<div
className="border-b bg-muted/50"
style={{ display: "grid", gridTemplateColumns: gridCols }}
>
{cols.map((col) => (
<div
key={col.columnName}
className="px-1 py-0.5 text-[8px] font-medium text-muted-foreground truncate"
>
{col.label}
</div>
))}
</div>
{/* 더미 행 */}
{Array.from({ length: dummyRows }).map((_, i) => (
<div
key={i}
className="border-b last:border-b-0"
style={{ display: "grid", gridTemplateColumns: gridCols }}
>
{cols.map((col) => (
<div
key={col.columnName}
className="px-1 py-0.5"
>
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
</div>
))}
</div>
))}
</div>
);
}
// ===== 카드 미리보기 =====
function CardPreview({
cardGrid,
}: {
cardGrid: PopStringListConfig["cardGrid"];
}) {
if (!cardGrid || cardGrid.cells.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<span className="text-[8px] text-muted-foreground">
</span>
</div>
);
}
// 더미 카드 2장
return (
<div className="space-y-1">
{[0, 1].map((i) => (
<div
key={i}
className="rounded border"
style={{
display: "grid",
gridTemplateColumns: cardGrid.colWidths.join(" "),
gridTemplateRows:
cardGrid.rowHeights?.join(" ") ||
`repeat(${cardGrid.rows}, 1fr)`,
gap: `${cardGrid.gap}px`,
minHeight: "24px",
}}
>
{cardGrid.cells.map((cell) => (
<div
key={cell.id}
className="flex items-center justify-center"
style={{
gridColumn: `${cell.col} / span ${cell.colSpan}`,
gridRow: `${cell.row} / span ${cell.rowSpan}`,
border: cardGrid.showBorder
? "1px dashed hsl(var(--border))"
: "none",
}}
>
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
</div>
))}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,38 @@
"use client";
/**
* pop-string-list
*
* import하면 side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopStringListComponent } from "./PopStringListComponent";
import { PopStringListConfigPanel } from "./PopStringListConfig";
import { PopStringListPreviewComponent } from "./PopStringListPreview";
import type { PopStringListConfig } from "./types";
// 기본 설정값
const defaultConfig: PopStringListConfig = {
displayMode: "list",
header: { enabled: true, label: "" },
overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 },
dataSource: { tableName: "" },
listColumns: [],
cardGrid: undefined,
};
// 레지스트리 등록
PopComponentRegistry.registerComponent({
id: "pop-string-list",
name: "리스트 목록",
description: "테이블 데이터를 리스트 또는 카드 형태로 표시",
category: "display",
icon: "List",
component: PopStringListComponent,
configPanel: PopStringListConfigPanel,
preview: PopStringListPreviewComponent,
defaultProps: defaultConfig,
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -0,0 +1,67 @@
// ===== pop-string-list 전용 타입 =====
// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import.
import type { CardListDataSource } from "../types";
/** 표시 모드 */
export type StringListDisplayMode = "list" | "card";
/** 카드 내부 셀 1개 정의 */
export interface CardCellDefinition {
id: string;
row: number; // 1부터
col: number; // 1부터
rowSpan: number; // 행 병합 (기본 1)
colSpan: number; // 열 병합 (기본 1)
columnName: string; // 바인딩할 DB 컬럼명
label?: string; // 셀 위에 표시할 라벨 (선택)
labelPosition?: "top" | "left"; // 라벨 위치 (기본 top)
type: "text" | "image" | "badge" | "button"; // 셀 렌더링 타입
fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px)
align?: "left" | "center" | "right"; // 가로 정렬 (기본 left)
verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top)
}
/** 카드 그리드 레이아웃 설정 */
export interface CardGridConfig {
rows: number; // 행 수
cols: number; // 열 수
colWidths: string[]; // fr 단위 배열 (예: ["2fr", "1fr", "1fr"])
rowHeights?: string[]; // px 단위 배열 (예: ["32px", "48px"], 기본 32px 균등)
gap: number; // 셀 간격 (px, 2~8 권장)
showBorder: boolean; // 셀 보더 표시
cells: CardCellDefinition[]; // 셀 목록
}
/** 리스트 모드 컬럼 1개 설정 */
export interface ListColumnConfig {
columnName: string; // DB 컬럼명
label: string; // 헤더 라벨
width?: string; // fr 단위 (기본 "1fr")
align?: "left" | "center" | "right"; // 정렬
alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록
}
/** 오버플로우 설정 */
export interface OverflowConfig {
visibleRows: number; // 기본 표시 행 수
showExpandButton: boolean; // "전체보기" 버튼 표시
maxExpandRows: number; // 확장 시 최대 행 수
}
/** 헤더 설정 */
export interface StringListHeaderConfig {
enabled: boolean; // 헤더 표시 여부
label?: string; // 헤더 라벨 텍스트
}
/** pop-string-list 전체 설정 */
export interface PopStringListConfig {
displayMode: StringListDisplayMode;
header: StringListHeaderConfig;
overflow: OverflowConfig;
dataSource: CardListDataSource; // 기존 타입 재활용
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
cardGrid?: CardGridConfig; // 카드 모드 전용
}

View File

@ -354,6 +354,7 @@ export interface CardColumnJoin {
joinType: "LEFT" | "INNER" | "RIGHT";
sourceColumn: string; // 메인 테이블 컬럼
targetColumn: string; // 조인 테이블 컬럼
selectedTargetColumns?: string[]; // 가져올 대상 테이블 컬럼 목록
}
// ----- 필터 설정 -----